From abc1d37c67acb03ed5130f5308046f80f3fd571e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 16:58:47 -0700 Subject: [PATCH] Lyrics on every screen: one language, the answer shown more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every lyric line carries the score's lead/hum role and an `answer` flag (named "the answer …", or the middle of a call and response). The laptops draw the lead line as the headline with syllables lit as sung, the singer's name above and the next lead faint beneath; an answer is bigger, framed, flashes white on its first syllable and lingers; hums breathe small along the bottom. fleet/lyric-graphics.js is the same language for the dark canvases (Xbox, ac7, Windows) as a drop-in drawTrioRoom(), with a preview page; LYRICS-SCREENS.md holds the rules. Co-Authored-By: Claude Fable 5.1 --- .../macneopolitan/LYRICS-SCREENS.md | 45 +++++++ .../macneopolitan/bin/trio-fleet-plan.mjs | 15 ++- .../fleet/lyric-graphics-preview.html | 22 ++++ .../macneopolitan/fleet/lyric-graphics.js | 69 +++++++++++ .../macneopolitan/fleet/native-trio.mjs | 114 ++++++++++++------ 5 files changed, 229 insertions(+), 36 deletions(-) create mode 100644 grants/culturehub-la-2026/macneopolitan/LYRICS-SCREENS.md create mode 100644 grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics-preview.html create mode 100644 grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics.js diff --git a/grants/culturehub-la-2026/macneopolitan/LYRICS-SCREENS.md b/grants/culturehub-la-2026/macneopolitan/LYRICS-SCREENS.md new file mode 100644 index 0000000000..3c080cb2fe --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/LYRICS-SCREENS.md @@ -0,0 +1,45 @@ +# Lyrics on every screen — the room's one language + +For the MacNeoPolitan Trio's pieces, every screen in the room shows the words +the same way. Drafted 2026-09-24 for the 7:00 show. + +## The rules + +1. **Headline = the lead line being sung.** Each lyric line carries the + score's own role, `lead` or `hum` (`lineRoles`, carried into `plan.lyrics` + by `bin/trio-fleet-plan.mjs`). The current lead line is the headline: as + large as fits in three rows, the **singer's name small above it in their + colour**, the **next lead line faint beneath**. Syllables light as they are + sung (`syllables[].t`); unsung syllables are dim. +2. **The answer is shown more.** A line is an `answer` when the piece names + it ("the answer is four"), or — in a piece that names none — when it is + the middle of a call and response: a lead line by one member following + another member's lead line within a bar and followed within a bar by a + third member's line. An answer is drawn one size bigger, framed in a tint + of the room colour with a bar beneath, **flashes to white for its first + quarter second**, and **lingers 2.5 s** after it ends (other lines 0.8 s). +3. **Hums are texture, not words.** `ooh ooh`, `dm dm dm dm`, `hmm hmm` are + never the headline: small, along the bottom, breathing with the beat, at + most three at once. +4. **Colour.** neo citrus `[143,209,63]`, blueberry indigo `[90,87,211]`, + frisbee blush `[242,167,185]`. On the six laptops the whole screen is the + seat's colour and the text is black or near-white by luminance; on the + Xbox, ac7 and the Windows page the ground is dark and the text is the + member's colour, with the six seats standing as dim breathing figures. +5. **Readable from the back of the room.** Laptops: headline size ≥ 3 + (30 px glyphs), name size 2, hums size 2, ≤ 3 rows. Canvas screens: + headline ≥ 28 px, answers up to 20 % of the height. + +## Where it lives + +- Laptops: `fleet/native-trio.mjs` `paint()` (immediate mode, cursors, no + per-frame allocation; bench with `fedac/native/tests/paint-density-bench.mjs`). +- Dark canvases: `fleet/lyric-graphics.js` exporting + `drawTrioRoom(ctx, w, h, music, elapsed, now)`; preview at + `fleet/lyric-graphics-preview.html` (open in a browser from the lane). + The Xbox/ac7 renderer (`oskiewar-dance.js` on blueberry's lane, deployed + by neo's stage) and the Windows sub page adopt it by calling + `drawTrioRoom` where they now call `drawTrioLyric(music, elapsed)`, with + `elapsed` extrapolated locally from the last packet. +- Feed: the runner (`bin/run-full-trio.py`) posts `lyric` (with `role`, + `answer`, `syllables`, `syllable`), `next` and `faces` at 5 Hz. diff --git a/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs b/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs index 34874cdf3d..61648029cf 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs @@ -77,9 +77,22 @@ export function buildPlan(score,profiles,fleet,levels={}) { lines.forEach((text,k)=>{const ns=parts[i].filter(n=>n.line===k);if(!ns.length)return; const toks=(v.lyrics.split(' / ')[k]||'').trim().split(/\s+/).filter(t=>t!=='/').flatMap(t=>t.split('-')); // one syllable per note phrases.push({member:members[i],memberIndex:i,phrase:k,text,t:ns[0].t,dur:Math.max(...ns.map(n=>n.t+n.dur))-ns[0].t,notes:ns.map(n=>n.note), + role:v.lineRoles?.[k]??'lead', // the score's own word: lead or hum syllables:ns.map((n,j)=>({t:+n.t.toFixed(3),dur:+n.dur.toFixed(3),text:toks[j]||'',note:n.note}))});}); }); phrases.sort((a,b)=>a.t-b.t||a.memberIndex-b.memberIndex); + // The answer: the line the room should show more. Named outright ("the + // answer is four"), or the middle of a call and response — a lead line by + // one member that follows another member's lead line within a bar and is + // itself followed by a third member's line (question · answer · confirmation). + const leads=phrases.filter(p=>p.role==='lead'),barS=layerSources(score).beatsPerBar*60/score.bpm; + const named=leads.filter(p=>/^the answer\b/i.test(p.text)); + if(named.length)named.forEach(p=>p.answer=true); // a piece that names its answers has them; nothing else is one + else leads.forEach((p,i)=>{ + const prev=leads[i-1],next=leads[i+1]; + const middle=prev&&next&&prev.member!==p.member&&next.member!==p.member&&next.member!==prev.member&&p.t-(prev.t+prev.dur)events.push({id:`${layer}-${events.length}`,layer,receiver,...e,...extra}); // The voice bounce: every actual sung phrase is heard from a seat, then @@ -141,7 +154,7 @@ export function buildPlan(score,profiles,fleet,levels={}) { const plan={schema:'trio-fleet-plan-v1',title:score.title,bpm:score.bpm,duration:dur,levels,payloads,nodes,events, requiredReceivers:[...members.map(m=>`singer-${m}`),...nodes.map(n=>n.id),'sub','dmx'], layers:src,routes,colors:Object.fromEntries(members.map((m,i)=>[m,colors[i]])), - lyrics:phrases.map(p=>({t:p.t,dur:p.dur,text:p.text,member:p.member,rgb:colors[p.memberIndex],syllables:p.syllables})), + lyrics:phrases.map(p=>({t:p.t,dur:p.dur,text:p.text,member:p.member,rgb:colors[p.memberIndex],role:p.role,...(p.answer?{answer:true}:{}),syllables:p.syllables})), sections:(score.arrangement?.sections??[]).map(s=>({name:s.name,beat:s.beat})),arrangement:{total:score.arrangement?.total??null,meter:score.arrangement?.meter??null}, dmx:{host:'192.168.1.235',port:8790,activeAddresses:addresses,inactiveAddresses:[41,511]}, sub:{host:'192.168.1.67',port:8788,transport:'rustdesk-local-bridge'}, diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics-preview.html b/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics-preview.html new file mode 100644 index 0000000000..dbc3fb216d --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics-preview.html @@ -0,0 +1,22 @@ +lyric graphics preview + + + diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics.js b/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics.js new file mode 100644 index 0000000000..092c4a77e2 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/fleet/lyric-graphics.js @@ -0,0 +1,69 @@ +// lyric-graphics.js — the room's lyric language on a dark canvas (Xbox +// oskiewar, ac7, the Windows sub page). Self-contained; no dependencies. +// +// drawTrioRoom(ctx, w, h, music, elapsed, now) +// +// `music` is the venue feed's /api/performance for dance 'trio-round-v1': +// {title, bpm, duration, elapsed, lyric:{text,member,rgb,t,dur,role,answer, +// syllables:[{t,dur,text}],syllable}, next:{text,member,rgb,in}, +// faces:{neo,blueberry,frisbee:{text,rgb,t,dur,role}|null}}. `elapsed` is the +// caller's locally extrapolated score time; `now` is performance.now() ms. +// Rules (LYRICS-SCREENS.md): headline = the lead line being sung, syllables +// lit as sung, singer's name above, next lead faint beneath; an answer is +// bigger, framed, flashes white on its first syllable and lingers 2.5 s; hums +// are small, low and breathe with the beat; the six seats stand as dim +// figures around the words. +const MEMBER_RGB = { neo: [143, 209, 63], blueberry: [90, 87, 211], frisbee: [242, 167, 185] }; +const SEAT_RGB = [[143, 209, 63], [90, 87, 211], [242, 167, 185], [143, 209, 63], [90, 87, 211], [242, 167, 185]]; +const LINGER_ANSWER = 2.5, FLASH = 0.25; +let lastHead = null, lastHeadEnd = -1; +const rgba = (c, a = 1) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; +function wrap(ctx, text, maxW) { + const words = text.split(' '), rows = []; let row = ''; + for (const wd of words) { const cand = row ? row + ' ' + wd : wd; if (ctx.measureText(cand).width > maxW && row) { rows.push(row); row = wd; } else row = cand; } + if (row) rows.push(row); return rows; +} +function fitFont(ctx, text, maxW, maxPx, minPx, maxRows) { + for (let px = maxPx; px >= minPx; px -= 4) { ctx.font = `600 ${px}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; const rows = wrap(ctx, text, maxW); if (rows.length <= maxRows) return [px, rows]; } + ctx.font = `600 ${minPx}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; return [minPx, wrap(ctx, text, maxW)]; +} +function karaoke(ctx, line, elapsed, cx, y, px, rows, lit, dim) { + const syls = line.syllables || []; let sung = -1; for (let i = 0; i < syls.length; i++) { if (syls[i].t <= elapsed) sung = i; else break; } + let sylIdx = 0; const lineH = px * 1.18; + rows.forEach((rw, r) => { + const words = rw.split(' '), rowW = ctx.measureText(rw).width; let x = cx - rowW / 2; const yy = y + r * lineH; + for (const wd of words) { + let take = 0, acc = ''; while (sylIdx + take < syls.length && acc.length < wd.length) { acc += syls[sylIdx + take].text; take++; } + if (take === 0) take = 1; let px2 = x; + for (let k = 0; k < take; k++) { const piece = syls[sylIdx + k]?.text ?? wd; ctx.fillStyle = (sylIdx + k) <= sung ? lit : dim; ctx.fillText(piece, px2, yy); px2 += ctx.measureText(piece).width; } + sylIdx += take; x += ctx.measureText(wd + ' ').width; + } + }); + return rows.length * lineH; +} +export function drawTrioRoom(ctx, w, h, music, elapsed, now = 0) { + ctx.save(); ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + const beat = 60 / (music?.bpm || 100), pulse = Math.max(0, 1 - ((elapsed / beat) % 1) * 1.4); + // six dim seats, breathing + const cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.42; + for (let i = 0; i < 6; i++) { const a = -Math.PI / 2 + i * Math.PI / 3, x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R * 0.78; const c = SEAT_RGB[i]; ctx.fillStyle = rgba(c, 0.22 + 0.12 * pulse); ctx.beginPath(); ctx.arc(x, y - 18, 7, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = rgba(c, 0.28 + 0.12 * pulse); ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, y - 10); ctx.lineTo(x, y + 14); ctx.moveTo(x - 10, y); ctx.lineTo(x + 10, y); ctx.moveTo(x, y + 14); ctx.lineTo(x - 8, y + 30); ctx.moveTo(x, y + 14); ctx.lineTo(x + 8, y + 30); ctx.stroke(); } + const lyric = music?.lyric && music.lyric.role !== 'hum' ? music.lyric : null; + // the headline lingers after it ends: remember the last lead line + if (lyric) { lastHead = lyric; lastHeadEnd = lyric.t + lyric.dur; } + const head = lyric || (lastHead && elapsed < lastHeadEnd + (lastHead.answer ? LINGER_ANSWER : 0.8) && elapsed >= lastHead.t ? lastHead : null); + if (head) { + const rgb = head.rgb || MEMBER_RGB[head.member] || [240, 240, 240], isAnswer = !!head.answer, flash = isAnswer && elapsed >= head.t && elapsed < head.t + FLASH; + const [px, rows] = fitFont(ctx, head.text, w * 0.86, isAnswer ? Math.round(h * 0.2) : Math.round(h * 0.15), 28, 3); + const lineH = px * 1.18, blockH = rows.length * lineH, y0 = cy - blockH / 2 - (isAnswer ? 0 : 10); + if (isAnswer) { const pad = px * 0.5, bw = Math.max(...rows.map(r => ctx.measureText(r).width)) + pad * 2; ctx.fillStyle = flash ? 'rgba(255,255,255,0.95)' : rgba(rgb, 0.16); ctx.fillRect(cx - bw / 2, y0 - pad * 0.6, bw, blockH + pad * 1.2); ctx.fillStyle = flash ? '#fff' : rgba(rgb, 0.9); ctx.fillRect(cx - bw / 2, y0 + blockH + pad * 0.6, bw, Math.max(4, px * 0.08)); } + ctx.font = `600 ${Math.round(px * 0.32)}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; ctx.fillStyle = rgba(rgb, 0.75); const name = head.member || ''; ctx.fillText(name, cx - ctx.measureText(name).width / 2, y0 - px * 0.5); + ctx.font = `600 ${px}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; + karaoke(ctx, head, elapsed, cx, y0, px, rows, flash ? '#111' : rgba(rgb, 1), flash ? 'rgba(20,20,30,0.5)' : rgba(rgb, 0.38)); + const next = music?.next; if (next && next.text !== head.text) { const nrgb = next.rgb || MEMBER_RGB[next.member] || rgb; ctx.font = `600 ${Math.round(px * 0.4)}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; ctx.fillStyle = rgba(nrgb, 0.42); ctx.fillText(next.text, cx - ctx.measureText(next.text).width / 2, y0 + blockH + px * 0.7); } + } else if (music?.next) { const n = music.next, nrgb = n.rgb || MEMBER_RGB[n.member] || [200, 200, 200]; const [px, rows] = fitFont(ctx, n.text, w * 0.6, Math.round(h * 0.08), 24, 2); ctx.fillStyle = rgba(nrgb, 0.45); rows.forEach((r, i) => ctx.fillText(r, cx - ctx.measureText(r).width / 2, cy - px * 0.6 + i * px * 1.18)); } + // hums: each face's current hum, small and low, breathing + const faces = music?.faces || {}; let k = 0; + for (const m of ['neo', 'blueberry', 'frisbee']) { const f = faces[m]; if (!f || f.role !== 'hum') continue; const rgb = f.rgb || MEMBER_RGB[m]; const px = Math.round(h * 0.035); ctx.font = `600 ${px}px "Comic Sans MS", "Chalkboard SE", system-ui, sans-serif`; ctx.fillStyle = rgba(rgb, 0.35 + 0.25 * pulse); const x = w * (0.2 + k * 0.3) - ctx.measureText(f.text).width / 2; ctx.fillText(f.text, x, h * 0.86 - pulse * 6); k++; } + ctx.restore(); +} +export const trioRoomVersion = 'trio-room-v2'; diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs b/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs index 57d7f75a95..d6d35195f4 100644 --- a/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/fleet/native-trio.mjs @@ -110,56 +110,100 @@ const NOTE_RGB={c:[255,50,50],d:[255,160,0],e:[255,230,0],f:[50,200,50],g:[50,12 const NAMES=['c','c#','d','d#','e','f','f#','g','g#','a','a#','b']; function noteRgb(midi){const n=NAMES[((midi%12)+12)%12];return n.includes('#')?[235,235,235]:NOTE_RGB[n];} const KIND={kick:{lane:0,rgb:[255,90,60]},boom:{lane:0,rgb:[200,40,40]},snare:{lane:1,rgb:[245,245,245]},donk:{lane:1,rgb:[255,170,40]},hat:{lane:2,rgb:[170,170,170]},perc:{lane:1,rgb:[220,220,220]},riser:{lane:3,rgb:[120,200,255]},voice:{lane:3,rgb:[255,120,200]}}; -let noteCursor=0,lyricCursor=0,routeCursor=0,lastPaintT=-1; +let noteCursor=0,routeCursor=0,lastPaintT=-1; // Per-frame budgets: the roll draws at most NOTES marks (this seat's own // always, the room's until OTHERS), LABELS note names, and merges a run of // ticks in one lane closer than MERGE seconds. No arrays are made per frame. const NOTES=72,OTHERS=40,LABELS=16,MERGE=.06; const laneLastT=[-9,-9,-9,-9]; function inkRgb(ink,r,g,b,k){ink(Math.round(r*k),Math.round(g*k),Math.round(b*k));} +// The room's lyric language (LYRICS-SCREENS.md): the seat's colour edge to +// edge; the HEADLINE is the lead line being sung, syllables lit as sung, the +// singer's name above, the next lead line faint beneath; an ANSWER line is +// bigger, boxed in a tint, flashes to white on its first syllable and lingers +// after it ends; HUMS are texture: small, low, breathing with the beat. +const LINGER_ANSWER=2.5,LINGER=.8,FLASH=.25; +let headCursor=0,humCursor=0; +function layoutRows(words,w,maxSize,minSize,maxRows){ + // biggest size at which the words wrap into ≤ maxRows rows; returns [size, rows] + for(let size=maxSize;size>=minSize;size--){ + const rows=[];let row=''; + for(const wd of words){const cand=row?row+' '+wd:wd;if(textWidth(cand,size)>w*.9&&row){rows.push(row);row=wd;}else row=cand;} + if(row)rows.push(row); + if(rows.length<=maxRows||size===minSize)return [size,rows]; + } + return [minSize,[words.join(' ')]]; +} +function drawKaraoke(write,ink,line,t,x0,y0,size,rowH,w,litRGB,dimRGB){ + // syllables map onto words in order; each word takes the syllables that spell it + const words=line.text.split(' '),syls=line.syllables||[]; + let sung=-1;for(let i=0;iw*.9&&row){rowsOut.push(row);row=wd;}else row=cand;} + if(row)rowsOut.push(row); + for(const rw of rowsOut){ + let x=Math.round((w-textWidth(rw,sz))/2),y=y0+r*rowH;r++; + for(const wd of rw.split(' ')){ + let take=0,acc='';while(sylIdx+takeMath.round(v*.35));wipe(...bg); - const lum=(0.2126*bg[0]+0.7152*bg[1]+0.0722*bg[2])/255; - const inkRGB=lum>.5?[8,8,12]:[250,250,250],dimRGB=lum>.5?[Math.round(bg[0]*.55),Math.round(bg[1]*.55),Math.round(bg[2]*.55)]:[Math.round(bg[0]*.5+110),Math.round(bg[1]*.5+110),Math.round(bg[2]*.5+110)]; + const lum=(0.2126*bg[0]+0.7152*bg[1]+0.0722*bg[2])/255,dark=lum>.5; + const inkRGB=dark?[8,8,12]:[250,250,250]; + const dimRGB=dark?[Math.round(bg[0]*.55),Math.round(bg[1]*.55),Math.round(bg[2]*.55)]:[Math.round(bg[0]*.5+110),Math.round(bg[1]*.5+110),Math.round(bg[2]*.5+110)]; + const tintRGB=dark?[Math.min(255,bg[0]+40),Math.min(255,bg[1]+40),Math.min(255,bg[2]+40)]:[Math.round(bg[0]*.7),Math.round(bg[1]*.7),Math.round(bg[2]*.7)]; if(!cfg)return; if(error){ink(...inkRGB);write(error,{x:8,y:h-24,font:FONT,size:1});return;} if(phase==='countdown'){const left=Math.max(0,origin-sound.time);ink(...inkRGB);centered(write,String(Math.ceil(left)),Math.round(h/2-40),8,w);ink(...dimRGB);centered(write,(cfg.title||'').toLowerCase(),Math.round(h/2+50),2,w);return;} if(!active)return; - // the current line: advance a cursor, never scan - const lyrics=cfg.lyrics||[]; - while(lyricCursor=lyrics[lyricCursor].t+lyrics[lyricCursor].dur+.8)lyricCursor++; - if(lyricCursor>0&&t=lyrics[lyricCursor].t-.3?lyrics[lyricCursor]:null; - const nxt=lyrics[lyricCursor+(cur?1:0)]||null; - if(cur){ - const words=cur.text.split(' '),syls=cur.syllables||[]; - let sung=-1;for(let i=0;i=2;size--){rows=[];let row='';for(const wd of words){const cand=row?row+' '+wd:wd;if(textWidth(cand,size)>w*.9&&row){rows.push(row);row=wd;}else row=cand;}if(row)rows.push(row);if(rows.length<=3)break;} - const rowH=size*10+Math.round(size*4),y0=Math.round(h/2-rows.length*rowH/2)-10; - ink(...dimRGB);write(cur.member,{x:Math.round((w-textWidth(cur.member,2))/2),y:y0-30,font:FONT,size:2}); - // syllables map onto characters in order; a word's syllables are its pieces - let sylIdx=0; - rows.forEach((row,r)=>{ - let x=Math.round((w-textWidth(row,size))/2),y=y0+r*rowH; - for(const wd of row.split(' ')){ - // how many syllables does this word take? consume until their joined text covers the word - let take=0,acc='';while(sylIdx+take0&&t<(lyrics[headCursor-1]?.t??0))headCursor=0; // a new run: rewind + let head=null,next=null; + for(let i=headCursor;i=l.t+l.dur+linger){headCursor=i+1;continue;} + if(t>=l.t-.3){head=l;for(let j=i+1;j=head.t&&t0&&t<(lyrics[humCursor-1]?.t??0))humCursor=0; + while(humCursor=lyrics[humCursor].t+lyrics[humCursor].dur))humCursor++; + let shown=0;const breathe=.5+.5*Math.max(0,1-((t/beat)%1)*1.4); + for(let i=humCursor;it)break;if(l.role!=='hum'||t>=l.t+l.dur)continue; + const size=2,x=Math.round(w*(.18+shown*.32)-textWidth(l.text,size)/2),y=h-40-Math.round(breathe*6); + ink(...(dark?[Math.round(bg[0]*.45),Math.round(bg[1]*.45),Math.round(bg[2]*.45)]:[Math.round(bg[0]*.5+80),Math.round(bg[1]*.5+80),Math.round(bg[2]*.5+80)])); + write(l.text,{x,y,font:FONT,size});shown++; + } } export function act({event,sound}){if(event.is('keyboard:down')&&event.key==='Escape')stop(sound);} export function leave(){if(api)stop(api.sound);} -- 2.51.2