pixelcanvas.gno
7.28 Kb Β· 278 lines
1// Package pixelcanvas is a shared 16x16 pixel canvas: anyone can paint one
2// cell at a time from a fixed 9-colour palette, and the canvas renders in
3// gnoweb as a grid of coloured blocks.
4//
5// The canvas is a flat [Size*Size]int of palette indices held in realm state β
6// a fixed-size array rather than a tree, because every cell exists from the
7// start and the grid is walked in full on every render. Painting is
8// last-write-wins; the realm keeps who painted each cell and a per-address
9// tally, so the board doubles as a contribution leaderboard.
10//
11// A stateful app with nothing reusable to extract, so it ships as a lone realm
12// rather than a p/ library + demo pair.
13package pixelcanvas
14
15import (
16 "sort"
17 "strconv"
18 "strings"
19
20 "chain"
21 "chain/runtime"
22
23 "gno.land/p/nt/avl/v0"
24)
25
26// Size is the canvas edge length; the canvas is Size x Size cells.
27const Size = 16
28
29// palette maps a colour index to the block rendered for it. Index 0 is the
30// blank canvas. Kept parallel to paletteNames.
31var palette = []string{"β¬", "π₯", "π§", "π¨", "π©", "π¦", "πͺ", "π«", "β¬"}
32
33// paletteNames are the names Paint accepts, in palette order.
34var paletteNames = []string{"white", "red", "orange", "yellow", "green", "blue", "purple", "brown", "black"}
35
36// Package-level persistent state.
37var (
38 cells [Size * Size]int // palette index per cell, row-major
39 painters [Size * Size]string
40 tally = avl.NewTree() // address string -> *int paint count
41 strokes int // total paints ever
42 lastAt int64 // height of the most recent paint
43)
44
45// Paint colours the cell at (x, y) and returns the total number of strokes.
46//
47// Coordinates are 0-based with (0,0) at the top-left. Colour is a palette name
48// ("red", "blue", β¦) β see Palette. Painting over an existing cell is allowed:
49// the canvas is last-write-wins, which is the whole point of a shared board.
50func Paint(cur realm, x, y int, colour string) int {
51 if !cur.IsCurrent() {
52 panic("spoofed realm")
53 }
54 prev := cur.Previous()
55 if !prev.IsUserCall() {
56 panic("only an EOA via MsgCall can paint")
57 }
58 if x < 0 || x >= Size || y < 0 || y >= Size {
59 panic("out of bounds: x and y must be in [0," + strconv.Itoa(Size-1) + "]")
60 }
61 idx := colourIndex(colour)
62 if idx < 0 {
63 panic("unknown colour " + strconv.Quote(colour) + "; see Palette()")
64 }
65
66 addr := prev.Address()
67 i := y*Size + x
68 cells[i] = idx
69 painters[i] = addr.String()
70 strokes++
71 lastAt = runtime.ChainHeight()
72 bump(addr.String())
73
74 chain.Emit("Paint",
75 "addr", addr.String(),
76 "x", strconv.Itoa(x),
77 "y", strconv.Itoa(y),
78 "colour", paletteNames[idx],
79 )
80 return strokes
81}
82
83// colourIndex resolves a palette name to its index, or -1.
84func colourIndex(name string) int {
85 n := strings.ToLower(strings.TrimSpace(name))
86 for i, p := range paletteNames {
87 if p == n {
88 return i
89 }
90 }
91 return -1
92}
93
94// bump increments an address's paint tally.
95func bump(addr string) {
96 // avl/v0's Get returns a single `any`; a miss is a nil interface.
97 if v := tally.Get(addr); v != nil {
98 c := v.(*int)
99 *c++
100 return
101 }
102 one := 1
103 tally.Set(addr, &one)
104}
105
106// At returns the palette name of the cell at (x, y), or "" when out of bounds.
107func At(x, y int) string {
108 if x < 0 || x >= Size || y < 0 || y >= Size {
109 return ""
110 }
111 return paletteNames[cells[y*Size+x]]
112}
113
114// PainterAt returns the address that last painted (x, y), or "" if untouched.
115func PainterAt(x, y int) string {
116 if x < 0 || x >= Size || y < 0 || y >= Size {
117 return ""
118 }
119 return painters[y*Size+x]
120}
121
122// Strokes returns how many paints the canvas has taken.
123func Strokes() int { return strokes }
124
125// Palette returns the accepted colour names, comma-separated.
126func Palette() string { return strings.Join(paletteNames, ", ") }
127
128// scorer is one row of the contribution leaderboard.
129type scorer struct {
130 addr string
131 count int
132}
133
134// byCount ranks painters by strokes descending, ties broken on address so the
135// board is deterministic.
136type byCount []scorer
137
138func (s byCount) Len() int { return len(s) }
139func (s byCount) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
140func (s byCount) Less(i, j int) bool {
141 if s[i].count != s[j].count {
142 return s[i].count > s[j].count
143 }
144 return s[i].addr < s[j].addr
145}
146
147// Render renders the canvas for gnoweb.
148//
149// Render("") / Render("/") -> the canvas, palette and leaderboard
150// Render("/<x>,<y>") -> who painted that cell, and what colour
151func Render(path string) string {
152 var b strings.Builder
153 b.WriteString("# Pixel Canvas\n\n")
154 b.WriteString("A shared ")
155 b.WriteString(strconv.Itoa(Size))
156 b.WriteString("x")
157 b.WriteString(strconv.Itoa(Size))
158 b.WriteString(" canvas. Anyone can `Paint(x, y, colour)` β last write wins.\n\n")
159
160 if q := parseArg(path); q != "" {
161 return b.String() + renderCell(q)
162 }
163
164 b.WriteString(grid())
165 b.WriteString("\n**")
166 b.WriteString(strconv.Itoa(strokes))
167 b.WriteString("** stroke")
168 if strokes != 1 {
169 b.WriteString("s")
170 }
171 b.WriteString(" Β· palette: ")
172 b.WriteString(Palette())
173 b.WriteString("\n")
174
175 if board := leaderboard(); board != "" {
176 b.WriteString("\n## Top painters\n\n")
177 b.WriteString(board)
178 }
179 return b.String()
180}
181
182// grid renders the canvas as rows of coloured blocks.
183func grid() string {
184 var b strings.Builder
185 for y := 0; y < Size; y++ {
186 for x := 0; x < Size; x++ {
187 b.WriteString(palette[cells[y*Size+x]])
188 }
189 b.WriteString("\n")
190 }
191 return b.String()
192}
193
194// leaderboard renders the top painters, or "" when nobody has painted.
195func leaderboard() string {
196 scores := []scorer{}
197 tally.Iterate("", "", func(k string, v any) bool {
198 scores = append(scores, scorer{addr: k, count: *(v.(*int))})
199 return false
200 })
201 if len(scores) == 0 {
202 return ""
203 }
204 sort.Sort(byCount(scores))
205 if len(scores) > 5 {
206 scores = scores[:5]
207 }
208
209 var b strings.Builder
210 b.WriteString("| # | address | strokes |\n|---|---|---|\n")
211 for i, s := range scores {
212 b.WriteString("| ")
213 b.WriteString(strconv.Itoa(i + 1))
214 b.WriteString(" | `")
215 b.WriteString(s.addr)
216 b.WriteString("` | ")
217 b.WriteString(strconv.Itoa(s.count))
218 b.WriteString(" |\n")
219 }
220 return b.String()
221}
222
223// renderCell renders a single "x,y" lookup.
224func renderCell(q string) string {
225 var b strings.Builder
226 x, y, ok := parseCoord(q)
227 if !ok {
228 b.WriteString("_Expected `x,y` β e.g. `/3,7`._\n")
229 return b.String()
230 }
231 b.WriteString("## Cell (")
232 b.WriteString(strconv.Itoa(x))
233 b.WriteString(", ")
234 b.WriteString(strconv.Itoa(y))
235 b.WriteString(")\n\n")
236 b.WriteString(palette[cells[y*Size+x]])
237 b.WriteString(" **")
238 b.WriteString(At(x, y))
239 b.WriteString("**\n\n")
240 if p := PainterAt(x, y); p != "" {
241 b.WriteString("Painted by `")
242 b.WriteString(p)
243 b.WriteString("`.\n")
244 } else {
245 b.WriteString("_Never painted._\n")
246 }
247 return b.String()
248}
249
250// parseArg extracts the first path segment.
251func parseArg(path string) string {
252 s := strings.TrimSpace(path)
253 s = strings.TrimPrefix(s, "/")
254 if i := strings.IndexByte(s, '/'); i >= 0 {
255 s = s[:i]
256 }
257 return s
258}
259
260// parseCoord parses "x,y" and reports whether it is in bounds.
261func parseCoord(s string) (int, int, bool) {
262 i := strings.IndexByte(s, ',')
263 if i < 0 {
264 return 0, 0, false
265 }
266 x, err := strconv.Atoi(strings.TrimSpace(s[:i]))
267 if err != nil {
268 return 0, 0, false
269 }
270 y, err2 := strconv.Atoi(strings.TrimSpace(s[i+1:]))
271 if err2 != nil {
272 return 0, 0, false
273 }
274 if x < 0 || x >= Size || y < 0 || y >= Size {
275 return 0, 0, false
276 }
277 return x, y, true
278}