Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

overlay.gno

13.64 Kb · 408 lines
  1package nsview
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/nt/ufmt/v0"
  9	"gno.land/r/nym-mnsrehearsal001/nsdata/v1"
 10)
 11
 12// Seasonal decoration, driven by DATA rather than by code.
 13//
 14// The card is generated on every read, so anything it draws can change
 15// after names have been sold. That makes a holiday overlay possible —
 16// snow in December, falling leaves in October — across every token at
 17// once. The temptation is to write a drawSnow() into this realm and a
 18// drawPumpkins() next year, which would make every new decoration a
 19// redeploy of the view.
 20//
 21// So it is parameterised instead. This realm knows how to draw drifting
 22// particles; WHICH particles, what colour, how many and until when live
 23// in a global that any admin call can rewrite. Christmas is then one
 24// transaction, not a deployment, and so is turning it off.
 25//
 26// Format, read from the global key `cardfx`:
 27//
 28//	dir:fall;n:22;c:#ffffff;c2:#dbeafe;s:3;d:9;until:1767225600
 29//
 30//	dir    fall | rise | burst    drift down, drift up, or fireworks
 31//	n      1..40                  how many
 32//	c      #rrggbb                colour (c2, c3 optional, cycled)
 33//	s      1..6                   pixel size
 34//	d      2..30                  seconds for one traverse (lower = faster)
 35//	sv     0..3                   size jitter, so they are not all identical
 36//	k      4..16                  sparks per shell, when dir is burst
 37//	sh     sq | leaf | petal      particle shape; leaf and petal are angled
 38//	until  unix seconds, 0 = no expiry
 39//
 40// `until` matters more than it looks: an overlay that expires on its own
 41// cannot be forgotten in January. Everything is parsed defensively and a
 42// malformed value yields NO overlay, for the same reason the sprite
 43// parser does — this markup lands inside a token that other people's
 44// software renders.
 45const keyFX = "cardfx"
 46
 47func overlaySVG(now int64) string {
 48	return buildOverlay(nsdata.GetGlobal(keyFX), now)
 49}
 50
 51// A SCHEDULE, so December snows without anybody remembering to make it.
 52//
 53// This is free in a way the treasury swap was not. That needed somebody
 54// to send a transaction, because a realm cannot act on its own. This
 55// needs nobody, because the card is generated on every read and can
 56// simply look at the clock — the decoration is a rendering decision, not
 57// an action.
 58//
 59// Rules are separated by "|" and the FIRST one whose window contains
 60// today wins, so put the specific ones first:
 61//
 62//	from:12-01;to:01-06;dir:fall;n:26;c:#ffffff|from:10-24;to:11-02;dir:rise;n:22;c:#f97316
 63//
 64// from/to are MM-DD and RECUR every year, which is the point — set it
 65// once and every December snows. A window whose start is after its end
 66// wraps the new year, so 12-01 to 01-06 is what you would expect rather
 67// than an empty range. A rule with no window is always eligible, so a
 68// bare spec still behaves as it did.
 69func pickRule(spec string, now int64) string {
 70	if spec == "" {
 71		return ""
 72	}
 73	t := time.Unix(now, 0).UTC()
 74	today := int(t.Month())*100 + t.Day()
 75	for _, rule := range strings.Split(spec, "|") {
 76		/* A WEEKDAY RULE, for the one date that is not a date.
 77		 *
 78		 * Friday the thirteenth falls one to three times a year and never
 79		 * on a schedule, so it cannot be written as a MM-DD window. `on`
 80		 * takes a weekday and an optional day-of-month — on:fri-13 — and
 81		 * is checked before the window fields so a rule can use either
 82		 * without the two interfering.
 83		 *
 84		 * Deliberately narrow: this is not a general calendar language,
 85		 * it is the one shape the existing one could not express. */
 86		if on := ruleField(rule, "on"); on != "" {
 87			if weekdayMatch(on, t) {
 88				return rule
 89			}
 90			continue
 91		}
 92		from := ruleField(rule, "from")
 93		to := ruleField(rule, "to")
 94		if from == "" && to == "" {
 95			return rule // no window: always eligible
 96		}
 97		f, okF := mmdd(from)
 98		e, okE := mmdd(to)
 99		if !okF || !okE {
100			continue // a malformed window disables its rule, not the lot
101		}
102		if f <= e {
103			if today >= f && today <= e {
104				return rule
105			}
106		} else if today >= f || today <= e {
107			// wraps the year end, e.g. 12-01 to 01-06
108			return rule
109		}
110	}
111	return ""
112}
113
114func ruleField(rule, key string) string {
115	for _, part := range strings.Split(rule, ";") {
116		kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
117		if len(kv) == 2 && strings.TrimSpace(kv[0]) == key {
118			return strings.TrimSpace(kv[1])
119		}
120	}
121	return ""
122}
123
124// mmdd reads "12-01" into 1201. Rejects anything that is not a real
125// calendar date, so a typo cannot silently make a rule match all year.
126func mmdd(s string) (int, bool) {
127	if len(s) != 5 || s[2] != '-' {
128		return 0, false
129	}
130	m, err1 := strconv.Atoi(s[:2])
131	d, err2 := strconv.Atoi(s[3:])
132	if err1 != nil || err2 != nil || m < 1 || m > 12 || d < 1 || d > 31 {
133		return 0, false
134	}
135	return m*100 + d, true
136}
137
138func buildOverlay(spec string, now int64) string {
139	spec = pickRule(spec, now)
140	if spec == "" {
141		return ""
142	}
143	dir := "fall"
144	n := 0
145	size := 3
146	dur := 9
147	jitter := 0
148	sparks := 8
149	shape := "sq"
150	var until int64
151	cols := []string{}
152
153	for _, part := range strings.Split(spec, ";") {
154		kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
155		if len(kv) != 2 {
156			continue
157		}
158		k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
159		switch k {
160		case "dir":
161			if v == "fall" || v == "rise" || v == "burst" {
162				dir = v
163			}
164		case "n":
165			n = atoiClamp(v, 0, 40)
166		case "s":
167			size = atoiClamp(v, 1, 6)
168		case "d":
169			// Floor of 2, not 3: a 3-second traverse was the fastest the
170			// card could go, and rain wants to be faster than snow by more
171			// than that allows.
172			dur = atoiClamp(v, 2, 30)
173		case "sv":
174			jitter = atoiClamp(v, 0, 3)
175		case "k":
176			sparks = atoiClamp(v, 4, 16)
177		case "sh":
178			if v == "sq" || v == "leaf" || v == "petal" {
179				shape = v
180			}
181		case "until":
182			if x, err := strconv.ParseInt(v, 10, 64); err == nil && x >= 0 {
183				until = x
184			}
185		case "c", "c2", "c3":
186			if isHexColour(v) {
187				cols = append(cols, v)
188			}
189		}
190	}
191
192	if n <= 0 || len(cols) == 0 {
193		return ""
194	}
195	// Expired decoration draws nothing, so a forgotten overlay retires
196	// itself rather than snowing in July.
197	if until > 0 && now >= until {
198		return ""
199	}
200
201	if dir == "burst" {
202		return fireworks(n, sparks, size, dur, cols)
203	}
204
205	var b strings.Builder
206	b.WriteString(`<g opacity="0.85">`)
207	for i := 0; i < n; i++ {
208		// Deterministic scatter: the same token renders identically on
209		// every read, which matters because a marketplace may cache it.
210		h := hash(i)
211		x := int(h % 392)
212		delay := int(h/7) % (dur * 10)
213		drift := int(h/13)%14 - 7
214		wobble := dur + int(h/17)%5
215		from, to := -12, 412
216		if dir == "rise" {
217			from, to = 412, -12
218		}
219		col := cols[i%len(cols)]
220		// Not all the same size. Uniform particles read as a texture;
221		// a little variation reads as depth, for two bytes of spec.
222		px := size
223		if jitter > 0 {
224			px = size + int(h/23)%(jitter+1)
225		}
226		if shape == "sq" {
227			b.WriteString(ufmt.Sprintf(
228				`<rect x="%d" y="%d" width="%d" height="%d" fill="%s">`+
229					`<animateTransform attributeName="transform" type="translate" `+
230					`values="0 0;%d %d" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
231				x, from, px, px, col,
232				drift, to-from, wobble, delay/10, delay%10))
233			continue
234		}
235		// A shaped particle needs its own rotation, and an element can
236		// carry only one transform. Animating the group and rotating the
237		// shape inside it keeps the two independent — animating both on
238		// one element would make each leaf drift along its own angle
239		// instead of downward.
240		b.WriteString(ufmt.Sprintf(
241			`<g><animateTransform attributeName="transform" type="translate" `+
242				`values="0 0;%d %d" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>%s</g>`,
243			drift, to-from, wobble, delay/10, delay%10,
244			shapeAt(shape, x, from, px, int(h/31)%360, col)))
245	}
246	b.WriteString(`</g>`)
247	return b.String()
248}
249
250// A small deterministic spread. Not random: the same index must always
251// give the same particle, or a cached render and a fresh one disagree.
252func hash(i int) int {
253	h := (i+1)*2654435761 ^ 0x9e3779b9
254	if h < 0 {
255		h = -h
256	}
257	return h
258}
259
260func atoiClamp(s string, lo, hi int) int {
261	v, err := strconv.Atoi(s)
262	if err != nil {
263		return 0
264	}
265	if v < lo {
266		return lo
267	}
268	if v > hi {
269		return hi
270	}
271	return v
272}
273
274// Fireworks: a shell climbs, vanishes, and throws sparks outward.
275//
276// The cost question is worth answering plainly, because it is the first
277// decoration that is not one rect per particle. A shell is 1 rect for
278// the climb plus k rects for the burst, so `n:5;k:10` is 55 rects — the
279// same order as the 34 the rain already draws, and a fifth of the 128
280// the domain sprite draws on every card anyway. None of it is stored:
281// the card is built during a read, so this costs no gas and no storage,
282// only the browser's time. That is the real ceiling, so the totals are
283// capped below rather than left to whoever writes the spec.
284//
285// Two phases in one loop, driven by keyTimes on a single animation each,
286// because a <set> chain or nested <g> would double the element count for
287// the same picture.
288func fireworks(shells, sparks, size, dur int, cols []string) string {
289	if shells > 6 {
290		shells = 6 // 6 x 16 sparks is already 102 rects
291	}
292	var b strings.Builder
293	b.WriteString(`<g opacity="0.9">`)
294	for i := 0; i < shells; i++ {
295		h := hash(i * 7)
296		x := 40 + int(h%340)         // keep the burst off the very edge
297		burstY := 96 + int(h/11)%140 // where it stops climbing
298		lift := 400 - burstY         // how far it climbs from the base
299		// Shells are staggered across the cycle so they do not all go up
300		// together, and each has its own colour from the palette.
301		begin := int(h/29) % (dur * 10)
302		// One colour per shell, not one per spark. A real firework is a
303		// single charge, and mixing colours inside one burst reads as
304		// confetti rather than pyrotechnics.
305		col := cols[i%len(cols)]
306
307		// The climb. Held at the top for the rest of the cycle rather
308		// than looping straight away, and faded out at the moment of the
309		// burst so the eye reads one event, not two objects.
310		b.WriteString(ufmt.Sprintf(
311			`<rect x="%d" y="400" width="%d" height="%d" fill="%s" opacity="0">`+
312				`<animateTransform attributeName="transform" type="translate" `+
313				`values="0 0;0 -%d;0 -%d" keyTimes="0;0.4;1" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>`+
314				`<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;0.38;0.4;1" `+
315				`dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
316			x, size, size, col, lift, lift, dur, begin/10, begin%10, dur, begin/10, begin%10))
317
318		for j := 0; j < sparks; j++ {
319			g := hash(i*97 + j*13)
320			d := j * 16 / sparks // one of the 16 fixed directions
321			r := 34 + int(g%34)  // how far this spark travels
322			dx := r * dirCos[d] / 100
323			dy := r * dirSin[d] / 100
324			// Sparks fall as they fly, which is the whole difference
325			// between a firework and a starburst.
326			dy += r / 3
327			px := size
328			if int(g/7)%3 == 0 {
329				px = size + 1 // a few brighter, bigger sparks per shell
330			}
331			b.WriteString(ufmt.Sprintf(
332				`<rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0">`+
333					`<animateTransform attributeName="transform" type="translate" `+
334					`values="0 0;0 0;%d %d" keyTimes="0;0.4;1" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>`+
335					`<animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.4;0.52;1" `+
336					`dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
337				x, burstY, px, px, col,
338				dx, dy, dur, begin/10, begin%10, dur, begin/10, begin%10))
339		}
340	}
341	b.WriteString(`</g>`)
342	return b.String()
343}
344
345// Sixteen directions on a circle, as hundredths. A table rather than
346// math.Sin: the same integers must come out on every node, and this is
347// the whole of the trigonometry the card needs.
348var (
349	dirCos = [16]int{100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38, 0, 38, 71, 92}
350	dirSin = [16]int{0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38}
351)
352
353// shapeAt draws one non-square particle at a position, turned to its own
354// angle. Leaves and petals are two curves each rather than a polygon: a
355// leaf is pointed at both ends, a petal is round at one, and that single
356// difference is the whole of what distinguishes October from April.
357//
358// The numbers are derived from the particle size so `s` still means what
359// it means everywhere else, and both shapes stay legible at the size a
360// card is actually looked at.
361func shapeAt(kind string, x, y, px, ang int, col string) string {
362	w, hh := px, px*2
363	switch kind {
364	case "leaf":
365		// Wider than a petal at the same size. A lens with its control
366		// points at the particle width is a sliver, not a leaf.
367		w = px * 3 / 2
368		return ufmt.Sprintf(
369			`<path d="M0 -%dQ%d 0 0 %dQ-%d 0 0 -%dZ" fill="%s" transform="translate(%d,%d) rotate(%d)"/>`,
370			hh, w, hh, w, hh, col, x, y, ang)
371	case "petal":
372		return ufmt.Sprintf(
373			`<ellipse rx="%d" ry="%d" fill="%s" transform="translate(%d,%d) rotate(%d)"/>`,
374			w, hh, col, x, y, ang)
375	}
376	return ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>`, x, y, px, px, col)
377}
378
379// weekdayMatch reads "fri" or "fri-13": a weekday, optionally pinned to a
380// day of the month. Anything it does not understand matches nothing, so a
381// typo disables its own rule rather than firing every day.
382func weekdayMatch(spec string, t time.Time) bool {
383	day := ""
384	dom := 0
385	if at := strings.Index(spec, "-"); at > 0 {
386		day = spec[:at]
387		n, err := strconv.Atoi(spec[at+1:])
388		if err != nil || n < 1 || n > 31 {
389			return false
390		}
391		dom = n
392	} else {
393		day = spec
394	}
395	names := map[string]time.Weekday{
396		"sun": time.Sunday, "mon": time.Monday, "tue": time.Tuesday,
397		"wed": time.Wednesday, "thu": time.Thursday, "fri": time.Friday,
398		"sat": time.Saturday,
399	}
400	want, ok := names[strings.ToLower(day)]
401	if !ok {
402		return false
403	}
404	if t.Weekday() != want {
405		return false
406	}
407	return dom == 0 || t.Day() == dom
408}