From 06b7eab6a45510ac22d04fb670f6435b719d7e60 Mon Sep 17 00:00:00 2001 From: lavs <79836722+SummonLav@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:58:58 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(nightbloom):=20a=20vertical=20danmaku?= =?UTF-8?q?=20garden=20=E2=80=94=20the=20Imperishable=20Night=20grammar=20?= =?UTF-8?q?on=20the=20deterministic=20runtime=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(demos): nightbloom — a plants-vs-yokai lane defense on the deterministic runtime One shrine, five lanes, an eternal night: a garden-defense action game in the Plants vs. Zombies grammar, flavored after the eternal-night yokai stories of the Touhou fangame tradition, and simulated in fixed 1/60 s micro-ticks so every simulationHz plays the SAME battle: - plants and foes both evolve I -> II -> III on two laws: plants by their own work (damage dealt / lumen gathered / blows endured, feedable), foes by the night phase — and on the spot when they finish eating a plant - attack/defense matchups as data: armor vs true petal damage, ranged mochi lobbers vs walls, a haste-aura songstress worth focusing first - possession: CIRCLE possesses any plant (the required cute cat included), held CIRCLE channels its fire, L/R switches, TRIANGLE casts its spell card — MOONRISE / PIERCING GALE / NINE LIVES / STONEHEART / PETALFALL - batches align to (t - tStart) * 60 ticks, edges land on the first tick of a frame, held input gates on a half-second plus a frame boundary, fx drift by battle-tick age: the 2 Hz world is a strict subsample of the 60 Hz world, gameplay included Content pipeline: demos/nightbloom/gen-assets.ts generates all 35 sprites and backdrops through the PixelLab pixel-art API from the seeded manifest in data.ts (stats, prompts and filenames in one table, checked by validateContent); evolution stages are init_image chains so a creature keeps its identity as it ascends. Assets are committed; the script is a no-op unless one is deleted or --force is passed. .env (the API key) is now gitignored. test/nightbloom.sim.test.ts drives THE GARDENER (a full 170 s winning night: 31 kills, one ward spent, a stage III bloom) and THE SLEEPER (an untouched loss) through the sim host and asserts repeat identity, chaos immunity, strict 4/2 Hz subsampling of both tapes, cross-rate byte-equal outcome screens, augury effect timing, and the exact end-screen ledger; wired into bun run test. Co-Authored-By: Claude Fable 5 * feat(nightbloom): rework as a vertical danmaku shooter — the Imperishable Night grammar The garden was a lane defense; the night wanted a bullet-hell. NIGHTBLOOM is now a vertical STG: the player PILOTS one plant at the bottom of a portrait playfield (centered on the 480x272 screen, HUD panels both sides), the yokai horde descends from the treeline, and CIRCLE / L / R switch the piloted form mid-fight — the Imperishable Night verb, with the same PixelLab bestiary: - five pilotable forms with distinct attack/defense trades: homing orbs (the cat), piercing bolts, a true-damage petal fan, an armored tank with a full-heal spell, and a moonlight gatherer; spell cards double as bullet clears - two evolution laws: forms grow by their own work (damage dealt, motes gathered — auto-collected above the PoC line — and bullets grazed), the horde grows with the hour (dusk I, midnight II, witching III) - nine waves, a midboss, and a three-card final boss with Touhou-style timeouts; enemy danmaku is native dots off a QUANTIZED sine table (1/8192 steps) because raw Math.sin is not bit-specified across JS engines and a spiral must replay byte-exactly on every host - held verbs (movement, fire, focus) read the raw held mask — a hold's level track goes true at the same battle tick at every rate — while press edges land on a batch's first tick, so the 2 Hz world dodges the same spiral the 60 Hz world does, frame for frame test/nightbloom.sim.test.ts drives THE MARKSMAN (a full clear: every form flies, three reach stage III, the diva's last card breaks with 4 of 5 forms alive — score 8495, graze 61, 35 felled) and THE SLEEPER (an untouched loss) and asserts repeat identity, chaos immunity, strict 4/2 Hz subsampling of both tapes, cross-rate byte-equal outcome screens, augury effect timing, and the exact end-screen ledger. Co-Authored-By: Claude Fable 5 * feat(nightbloom): hit and impact sfx through a host sound sink Sound is an OUTPUT, never an input. The engine emits SfxKind events from deterministic tick state — the hit thock, the kill pop, hurt/wilt, graze pings, mote chimes, form-switch clicks, spell-card declarations, boss card breaks, and the dawn/eternal stingers — and reads nothing back, so the simulation is byte-identical with or without audio. demos/nightbloom/sfx.ts installs the sink where WebAudio exists (the web host): every voice is synthesized from oscillators plus one xorshift noise buffer (no assets, no pipeline), per-kind throttled, voice-capped, and resumed on the first user gesture per the browser autoplay policy. The headless sim and QuickJS-on-PSP never install a sink and skip the poke entirely. A __nightbloomSfxState seam lets harnesses confirm the device state without ears. Verified live: the AudioContext resumes on a trusted key press, a 26-second browser battle emits shoot/hit/kill/mote/graze/hurt/switch/ spell in sensible proportions, and voices drain to zero after their envelopes. bun run test stays green — the sim never hears a thing. Co-Authored-By: Claude Fable 5 * feat(nightbloom): cuter bestiary regeneration + uniform-scale scene fix All 35 PixelLab assets regenerated from softened prompts: kawaii chibi style strings (huge sparkling eyes, blushing cheeks, plump rounded bodies, pastel glow) and per-entry rewrites that trade fierce for fluffy — marshmallow pyre flames, a chubby umbrella warlord with toy blades, an extra-fluffy moon rabbit, storybook-spooky plum for the eternal night. Seeds and init_image evolution chains are unchanged, so every creature keeps its identity across stages; f-kasa-3 / f-uta-3 still dress the bosses. The sim never sees a pixel: the ledger (score 8495, graze 61, 35 felled) is byte-for-byte the same night. Scene stretch fix: the 256x128 backdrops were drawn inset-0 w-full h-full — 1.875x wide but 2.125x tall, a 13% vertical squash the moon wore visibly. Title and outcome screens now draw the scene at 480x240 (UNIFORM 1.875x, the tidelight rule) over a 32px letterbox band that the footer legend sits in. Co-Authored-By: Claude Fable 5 * feat(nightbloom): waking roster, the last breath, the moon cat and the mountain Three player-facing changes, one night: - The roster shrinks to three pilotable forms and WAKES as you play: only the CATNIP KIT answers at dusk (the other cards show ?); the first stage-II ascension wakes the sakura SAPLING, and the midboss's fall rouses MOON PRIMROSE. Bamboo and the stone lantern stay in the tables as reserve content. - No pilot switches itself anymore: when the piloted form dies, the LAST BREATH opens — a 1.5 s window (invulnerable, guns silent, banner flashing) to switch to a waking form. Miss it, or fall with nobody else awake, and the night takes the run. The sleeper tape now ends at ~34 s for exactly that reason. - New portraits: the catnip is a black-and-gold cat with a white moon on its brow that waxes as it ascends (crescent, crescent, full); the moon primrose is a gap-moe gorilla — a mountain of carved abs under the sweetest little face — regenerated through the same seeded init_image chains. The ledger moves (score 8145, graze 42, 34 felled, 3 of 3 awakened alive) and every determinism claim re-verifies: repeat identity, chaos immunity, strict 4/2 Hz subsampling of both tapes, byte-equal outcome screens. bun run test green. Co-Authored-By: Claude Fable 5 * feat(nightbloom): living avatars, pokemon growth, rainbow reveals, bananas, three true abilities Five player-facing touches, all on the tick: - The avatar is alive: a 1.2 s triangle-wave idle bob, a lean into the strafe, and an invuln blink — pure functions of the battle tick, so the animation subsamples as exactly as the sim. - Pokemon growth: each stage is its own portrait AND its own size — the avatar draws at 22 / 27 / 32 px as it ascends (the hitbox never grows). All three chains regenerated with baby -> grown -> majestic language; the moon cat and the ab-carved gorilla keep their approved looks. - When a ? card wakes, a rainbow comet (pink-cyan head, amber-pink tail) sweeps it left to right — 48 ticks of reveal, driven by unlockedAt. - MOON PRIMROSE throws BANANAS: heavy slow volleys of spinning fruit (rotation is a pure function of tick and shot id), new shot-banana art. - Three true abilities beyond shot shapes: the catnip dances with death (wider graze ring, double graze glow); the sakura heals — every damaging petal mends the most wounded waking form by 1, at the price of soft hits; the gorilla hits hard and gathers — motes are worth double glow to him. The ledger moves to score 8235 / graze 53 / 34 felled; every determinism claim re-verifies at 60/4/2 Hz. bun run test green. Co-Authored-By: Claude Fable 5 * feat(nightbloom): facing mirror, slanted particle reveals, world drift + prune unused art - The avatar mirrors into its strafe (scaleX on the sprite, lean on the container so the two transforms never argue): press LEFT and the moon cat looks left, and keeps looking there until it flies the other way. - The unlock reveal is now a SLANTED shine (18 degrees, two gradient bars) trailing twelve specks of pink/cyan/amber dust whose spacing widens quadratically — densest right behind the head, thinning out. Pure translateX off the reveal progress, deterministic as ever. - The world never stops scrolling: hovering foes (wisps, sparrows) sink 10 px/s with the starfield while on station, hold for 8 s instead of 12, then accelerate off — an unkilled monster always leaves the field with the view instead of parking forever. - Pruned everything the roster cut left behind: bamboo and stone-lantern defs, their six sprites, shot-bolt.png, bg-field.png (the STG field is native-rendered), the GALE beam, the STONEHEART shield, and the "bolt" shot kind. PlantId narrows to the three real forms; the pak drops to 37 entries with zero placeholders. Ledger: score 8280 / graze 56 / 34 felled, dawn at ~162 s; the sleeper falls at ~40 s. All determinism claims re-verified at 60/4/2 Hz. Co-Authored-By: Claude Fable 5 * feat(nightbloom): healer-soft sakura, banana boomerangs with a catch, per-art facing - The sakura is a healer first: petal damage drops to 3/4/5 (still true damage, still a wide fan) — her worth is the mending, one hp to the most wounded waking form per damaging petal. - The gorilla's bananas are BOOMERANGS now: they fly up, stall against 260 px/s^2, turn, and home back to his hand at 240 px/s. A banana never despawns on a hit — it cuts through on the way out and again on the way home (one touch per 0.3 s) — and only a CAUGHT banana can be thrown again: at most three aloft, with a hand-count of banana pips on the HUD while he pilots. Throw depth grows with his stage. - Facing correction per portrait: the sakura and the gorilla came out of the generator facing left, so their strafe mirror ran backwards. Each form now declares artFacing and the renderer mirrors facing * artFacing — every pilot looks where it flies. Ledger: score 8435 / graze 78 / 33 felled, dawn at ~182.8 s, all three awakened forms alive. Determinism re-verified at 60/4/2 Hz; bun run test green. Co-Authored-By: Claude Fable 5 * feat(nightbloom): boss metamorphoses — per-phase portraits, cries, and a harder diva Every spell-card change is now a visible, audible transformation: - Four new 64x64 boss portraits: IRON KASA GROWN WRONG for the midboss, and three diva forms — the stage dress, the flared-wing chanter, and a phoenix-like final form. The sprite AND its draw size change per card (52 -> 58 -> 66 px), the hit radius scales with the form, and the switch lands with a scale-in pulse and an expanding flash ring. - Each boss has its own cry, played on entry and on every metamorphosis: the diva gets a three-chirp bird call, the umbrella a beating metal clang over a low whoomp — synthesized like the rest of the sink. - The diva sings harder: card hp 720/830/950, denser rings (14), faster spirals and mochi pairs, +4 bullet speed. The gardener's tape still breaks the finale — at 184.1 s now, with the catnip down to 70 hp and 92 grazes on the ledger. Score 8575, all three forms alive. Cross-size init_image turned out to be a pixflux 500, so the two boss bases anchor identity by description; the diva's later forms chain from her first at 64x64. Determinism re-verified at 60/4/2 Hz; bun run test green. Co-Authored-By: Claude Fable 5 * docs(nightbloom): the whole game in the codex — a two-page in-game book SELECT now cycles a two-page book instead of one screen: - Page one, THE PILOT'S MANUAL: every control (fly, fire, focus, switch, spell, pause), the shape of the night (nine waves, the midboss, the diva's three transforming cards), the growth laws (glow from wounds, motes and grazes; the PoC line), and the LAST BREATH rule. - Page two, FORMS AND FOES: the three forms with their ability laws, the waking rules (catnip at dusk, ascend for the sapling, fell the umbrella for the ape), the foes with theirs, and the phase table. The native host does not soft-wrap text, so the book is typeset by hand into short lines and the data laws are split at a word boundary; foe laws are tightened to single lines so both columns sit inside 272 px. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .gitignore | 3 + assets/screenshots/nightbloom.png | Bin 0 -> 20952 bytes demos/nightbloom/README.md | 134 +++ demos/nightbloom/app.tsx | 746 +++++++++++++++++ demos/nightbloom/backend.ts | 45 + demos/nightbloom/bg-dawn.png | Bin 0 -> 30754 bytes demos/nightbloom/bg-eternal.png | Bin 0 -> 31059 bytes demos/nightbloom/bg-title.png | Bin 0 -> 27408 bytes demos/nightbloom/boss-kasa.png | Bin 0 -> 2141 bytes demos/nightbloom/boss-uta-1.png | Bin 0 -> 1993 bytes demos/nightbloom/boss-uta-2.png | Bin 0 -> 1853 bytes demos/nightbloom/boss-uta-3.png | Bin 0 -> 1940 bytes demos/nightbloom/data.ts | 667 +++++++++++++++ demos/nightbloom/engine.ts | 1287 +++++++++++++++++++++++++++++ demos/nightbloom/f-kasa-1.png | Bin 0 -> 625 bytes demos/nightbloom/f-kasa-2.png | Bin 0 -> 592 bytes demos/nightbloom/f-kasa-3.png | Bin 0 -> 541 bytes demos/nightbloom/f-usagi-1.png | Bin 0 -> 559 bytes demos/nightbloom/f-usagi-2.png | Bin 0 -> 497 bytes demos/nightbloom/f-usagi-3.png | Bin 0 -> 503 bytes demos/nightbloom/f-uta-1.png | Bin 0 -> 632 bytes demos/nightbloom/f-uta-2.png | Bin 0 -> 589 bytes demos/nightbloom/f-uta-3.png | Bin 0 -> 523 bytes demos/nightbloom/f-wisp-1.png | Bin 0 -> 323 bytes demos/nightbloom/f-wisp-2.png | Bin 0 -> 266 bytes demos/nightbloom/f-wisp-3.png | Bin 0 -> 315 bytes demos/nightbloom/gen-assets.ts | 171 ++++ demos/nightbloom/main.tsx | 9 + demos/nightbloom/mote.png | Bin 0 -> 398 bytes demos/nightbloom/p-catnip-1.png | Bin 0 -> 613 bytes demos/nightbloom/p-catnip-2.png | Bin 0 -> 655 bytes demos/nightbloom/p-catnip-3.png | Bin 0 -> 645 bytes demos/nightbloom/p-primrose-1.png | Bin 0 -> 682 bytes demos/nightbloom/p-primrose-2.png | Bin 0 -> 718 bytes demos/nightbloom/p-primrose-3.png | Bin 0 -> 650 bytes demos/nightbloom/p-sakura-1.png | Bin 0 -> 533 bytes demos/nightbloom/p-sakura-2.png | Bin 0 -> 568 bytes demos/nightbloom/p-sakura-3.png | Bin 0 -> 568 bytes demos/nightbloom/sfx.ts | 208 +++++ demos/nightbloom/shot-banana.png | Bin 0 -> 306 bytes demos/nightbloom/shot-mochi.png | Bin 0 -> 307 bytes demos/nightbloom/shot-orb.png | Bin 0 -> 269 bytes package.json | 2 +- test/nightbloom.sim.test.ts | 183 ++++ 44 files changed, 3454 insertions(+), 1 deletion(-) create mode 100644 assets/screenshots/nightbloom.png create mode 100644 demos/nightbloom/README.md create mode 100644 demos/nightbloom/app.tsx create mode 100644 demos/nightbloom/backend.ts create mode 100644 demos/nightbloom/bg-dawn.png create mode 100644 demos/nightbloom/bg-eternal.png create mode 100644 demos/nightbloom/bg-title.png create mode 100644 demos/nightbloom/boss-kasa.png create mode 100644 demos/nightbloom/boss-uta-1.png create mode 100644 demos/nightbloom/boss-uta-2.png create mode 100644 demos/nightbloom/boss-uta-3.png create mode 100644 demos/nightbloom/data.ts create mode 100644 demos/nightbloom/engine.ts create mode 100644 demos/nightbloom/f-kasa-1.png create mode 100644 demos/nightbloom/f-kasa-2.png create mode 100644 demos/nightbloom/f-kasa-3.png create mode 100644 demos/nightbloom/f-usagi-1.png create mode 100644 demos/nightbloom/f-usagi-2.png create mode 100644 demos/nightbloom/f-usagi-3.png create mode 100644 demos/nightbloom/f-uta-1.png create mode 100644 demos/nightbloom/f-uta-2.png create mode 100644 demos/nightbloom/f-uta-3.png create mode 100644 demos/nightbloom/f-wisp-1.png create mode 100644 demos/nightbloom/f-wisp-2.png create mode 100644 demos/nightbloom/f-wisp-3.png create mode 100644 demos/nightbloom/gen-assets.ts create mode 100644 demos/nightbloom/main.tsx create mode 100644 demos/nightbloom/mote.png create mode 100644 demos/nightbloom/p-catnip-1.png create mode 100644 demos/nightbloom/p-catnip-2.png create mode 100644 demos/nightbloom/p-catnip-3.png create mode 100644 demos/nightbloom/p-primrose-1.png create mode 100644 demos/nightbloom/p-primrose-2.png create mode 100644 demos/nightbloom/p-primrose-3.png create mode 100644 demos/nightbloom/p-sakura-1.png create mode 100644 demos/nightbloom/p-sakura-2.png create mode 100644 demos/nightbloom/p-sakura-3.png create mode 100644 demos/nightbloom/sfx.ts create mode 100644 demos/nightbloom/shot-banana.png create mode 100644 demos/nightbloom/shot-mochi.png create mode 100644 demos/nightbloom/shot-orb.png create mode 100644 test/nightbloom.sim.test.ts diff --git a/.gitignore b/.gitignore index bd61beb..84bd4e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules/ target/ .DS_Store +# secrets — PIXELLAB_API_KEY for demos/*/gen-assets.ts lives here +.env + # pass-1 transform cache (content-hash keyed; safe to delete any time) .cache/ # per-app build output of scripts/build.ts (dist/ is ignored repo-wide too) diff --git a/assets/screenshots/nightbloom.png b/assets/screenshots/nightbloom.png new file mode 100644 index 0000000000000000000000000000000000000000..fc82c6d7f675078b1eb0b93b561cbd72badfc222 GIT binary patch literal 20952 zcmce-XIN8P*Dfp(l%ik&0i{Xk0#c=yjnb=t(yK`Cy#}IErFW2~bfiP*RX{*MN~qG2 z9!lt;1qX% zs`)U2X&&G=!VX+}>~}mH%Lyl$Ju_1W&-6oTeX$d>+ z4`M&M&GPFW#=YYD#gqNiYWT&t$mvA=oLNQU%SW?MX-kr!>EFxU&|YZhNkIL&my@rz ziry&3R{qRnIj~^!#vu2E7l%5H=WA*ED9@DNM+Vxi&0@E?-1fjOqmv#n;}0F?{nN?t z;fG%HsO=!q{78qS=oIFqoQhBlssHaI_RHHg70VW0GmhEj84Vmno(A8f<=i+$P4?p2 zze9wB$^{{^aC|Mqr1((06YBAr(SS?TTi^wG;XyE=8S5egVY}i&bkl7@lzMZ9Uro6I z+tSBgHMa^n5BO#8K`*u3K&PGi?>epd_5kOEkRzw<`+PX{_%B0=-6GZYESp>LR~ z<}+D_J*2Mt8S|ldr_`+{@bQ4WTCX`A)})y)>WK`6l^B5{>MSSC#k^f{XvxXkw#)p} zD6@RuuQ1g%x#kYL+DD)}WX^WZSKyj?DU&wgIBt{Xxhd%a?yIxGYKO(v4dMFH(7Q=9 z(yLN8d&L$W-sNi-OW$(IVve>R`pKU>uZJ;Ytd@ZHmoI6IG(rN;7l@1n6fta?9feu=akbHvcQj_H>fpI}84qqZ`2IwsZKC&o_Z=$V?0WEsWoPyRjuV^$=bwTy=r zfymOfwf*>_{2?upOv7)vBBqO58qhMO4)LkkBSI=4o3u>N=|{%5I?R8c_SXj*C+nBV zbuj4nLFV8`6TJ-ceiLcgT)TcNcvF^p2{PV$$7x4&P3J+?ZdTN5C(}9?ay~OBady4W zhe<2*zeIU{8D+O#ZQg-F=UqaF++zYmhTIMNT~>yQ$QzZDO|F0g>(u8D!cO)aAb>GHCfk%XBFuD^UJM zV&ahu9>{PRk>qG=i)L|>`g;D2>^hy8O~Ifiq>Q>-jeYGT8V@>5~;#ZI>keHI7#Lz}6>8E7p`wz4T( z6jQ1-xBFztF;+{c<)^j0f(Y432&f>^!AG{r`P@blB-gSmDARnrdBitc^g4zu#kxV~ zgQ@K5VT%?nja<8p2a~MoH<7vM*UX2qZMW|@8qcUNqLbVOo~))`uSLVsN-g43*mz=K zC+}hgj|^2ZmDZ*ie9bf^aB1k|hs25(c|p+m8mq?*k7guY>E@wUYnYeRY zCeCK`FKs1|T&6i%xRF;4B&AwCGM#xvH8X%EmksgdlkApxkynj3oF2dF5YgeCWc#1T!gmE$YM&K0%nHhL$6=KT%m60P5Qs}wxLIyB8W_N2p%rh7UC`ML=d zQd_T`CjYRhO5kST&ML%N8Raq^OU%KO`dUA{c9S@rfR1TYhZ)NE=^wM0b}1BQv2Q+* zQR@CA;G-t+iK*taL(oJd<%9IuyzGuEoQyMrG%xXmETk15Q+M##%n%@BN_K?!5c?zxit+fURR z?`CML_bWa}6XG@>EFDNXlxZzyK3P&4<)&wjPa2VFSoXxFmaEq9kZIDCUldCIVOT{} z^Np0}!Fj(-8RH*uG;Hi$e*TxSGTFu3dkw$T+R)fj za+k%lS<052fRu%lWzBG)H}(Phhj9d>Y-z4uR>EhBM#!M^iEF;EX#ah@fm0s5`5{m_ zIi^~5bzpIyQKl92+tn$>wXt_V68=?csJqn+<5C#nPb}>4+zlbG^Y)YsC#ZK!(>njn z$QP+kc`;xPQ`&si%w}VpA?kGBo%F=-BRK)`P zN9cK9%pLwV!C27hZ;B@mKTuvBs4~CQOMvO&m90^8nC{))9?pXhDIP(EreA;|6~h`Fyu2*Fsu73p%&?r@QIy^T+95yvBR`XEZwG_)hwXpUg-}-c~IJ8Ooe@1K? z>i?OF^(~8P>8vo`p%6y#%+Eza@mPYEF)0l=7|X{Yqo!vjD}m*kKD}&N$>2&2nidXj zUroM9#}7A+IX9@NKLEA>HiK zc(0+Bw&2@MkV*MKfx5(83KSJLb+w2=>uJ6R&6uQbJO$Jt=1I9uMfgX6WgRlg=W zJS|$ak!;lxU+2VYt@fw(m*1YAL$5LAP@K7>Gn#w;+iccxFa9MJpIwi}-*l+&g?A#a2+PUG zQ)~H|i$PY@o`yWjUl?JglJ<-eNn)}VaVt$VG2=mx95wH`SB^FRu>lua1-4OrUmcg%(E#h<$KQ0fY!M~*BFb$xRzIibkTf6v$^;N+#2Wvvx zC(<)y-@aeY`Nhx5`&8{+#>Krb&uimNUp{2t*nAdRa&J9g*T8YJO+GmZ<&T-_53?D@ zwU;4E^}6=`K*VaoQo-}V6oDXP3G{ge#yxRrT_$L+7&nV23eFH@P`;eN7cOXjp?RF* z*^^&}61YnAEl7$|FZVeOJbl6v?yY(>1*&9~U@lV@R)jMIB5_Vy zM*5ya?+^RY2({mGdH(;C5H1nj$J$izW>URj38_X*e|3t&_jFIr_ z4|uk#&N_7QMz-`qC;G*7+hzR9K>rLTCHC0zIUmg zx5vNT6WY3|zT@9}v3gW-xHk5zwM}I_34rU3xSSx1ctisCZ0b_{sRd#pqVxe#0{x{j zd8c{K<3`pD!S`E=(Sn=GsRdDB=d5dO#--_PrifJ7QFM6TyAM-opr22JNI!`TPus1jX#JXdYpsk#bd>fgUCNIjaKEdo#amIM#-XsJZIkg& zpplcQWpf&6Jvh!?i*7YDA%Jr~WAV$lMU@bmV+ z^{fbmrDez3oZXPfH1Gv7yxhA)-eB@X!u{GIPByHiWCdAx5wHY2mBbRp*(%m7ltudM zY-_ze5*>(Ehsrcx>^-V#MPD5XVya=E_d&O*-Q3zWTc@w{cC%~4-tU^}hd|j$F4O8p z7*G+y>#0qxsXrd`u0W>vDqt4X<}b#`z{Mgt;I#47ola}%FNQ46?2Mv>TCq{2&I{N{ z6rWPeF`ajuvDMG~^Nv1`(Ps43i>i%BTCD4Hw&2Q^XKyt}csH^pJO@Pva-`>pFSj-u z=|Gmxq~Xidmg@r7F$wGX3qjioI;O_tigw}3t?+TT=WaUn`=~CK^Dv#1Dnj2$2lP2U zmGrhlA{nz)Z8`^DCVBA>m1Cc~f_27O*Le;^l)ROh%;y*LW}jgkT<(1wOQq(>)3+3M ztB+D_aXHCMh0pm!c+C`N;{2=>Ieg;uyelFJ607ruZzds2vCe!^TTZngwPv-_KNG!c zZPrwne))G#2~zFyvsnp*PsMAH-B|`)pyXgp-!+fVu$m!>lHKX;7us?Q*0&BP9>wQP z=}yY`#{(dXYTwehyC`=Feq$q3vO%{V$>A|8J6&Q_7tF@z|&52f#G^`q4%( zusKl%cFvc*=WF64l*Y7F8caNa-+~pIDU?S9V6W__o+_ib< zAGq;zk&W(>g|o|{?YnWnizTB*GpeZIo9EXXpRU`5K4JL=K7br4N8fq*c?e5fl>4q^ zBB}#*kD7(BNXyNs%)cPgE6e9sD`q;v!oqC69_6!8cD|dvSR#M^ZR2XiE8vn9z2T?b zIHhB}(YOJ38)$HeS+g*d+AtL|+n;_>uBb5wKufx*DU-2YgBcp zc*Do8)R9{_T+ypw1z_L`|eofS_I&hL{a$T(Q7$uIe{U&pLa6CM)8ez(6 z&-;BIdZ;I{>Nh!pX;$kXV;%Ag^uJ!Cb24T#HyFj(JqM_tdjRrC$ibcSD+>w_%J#i9 zLhme2HB6aY+I^FuZ`(Gbk79Ux_Puq{CGh5iUJ0R)P;dXT?y(FFA=LxtdL>;G6S{PN zKR%m&wLNOIp~2Zr+2}|67YNEa^}mt?&Vl(P*nud8ZkJf( z;+SsMYRCvV)V}(8(0qg*2|bFVZ>%T}=*z77!-xKgGy549b(;ShB==M$N3{BDq@t8+ zyh2lS_2Vn4_ZGx;m9P*DR5Mwg&OvRSQ}M?^gzVJ zCQie?M()+H!2NTBxsG3Q&76el_yG6qPQF3v`AHp}l)2mI79 zojUOM>p#Id%74`DLN`+!ME~U5qk}`?^{_?s`+xval$dwM>=%@FxmPxW9QF59Ue8Dd z^WVfE%XrHi3qFfOH6@3Brhy<;_q4=M_lm6uKldridnOIxnS~2X%ge`zT6}7WRnbo5 z+_n7*SyghDAkS~N2j%CTQCPYMvi!3*7AW(djXo>g@pUt-^th8&q7ibNWrqhdvx%Q7 zO8Q+giIx{70c0j)P8{Fo5;jpnDhBs zlxHYYWipB@PK5ND-;|OIT!xUA!e?{}&Ef=mQ;y3hob%lf)iK2h<4)1RrW~zlgu^C8 z!rLCB#Q;u!7WPi)5#RI)y}%c8454PSPDfbuF&9UyIpDZ<3LLTnh#C_Op+Ffe6YT=( z{^FBv1RuM#)Oj6DIPxOS%asj+_p9}j)H0uU4m0hmiv0c%+_?Tk}e!<{+BWPow zJ$qs7Jjk2;OseX*;8rXu$j0Rn4uG=&00pw9#9^GdX)|k}to#eNWq(QQYOx)2)+*ou z?&MYJ2myk->liVJg21VL;F=DK(%AzaY2@YnFb4!8;0ZEg-#~Ij5JSnElKME?d@3YlPTML7e-st3{}wL4H27$N$7>+d^N~eVAJy-6lc_R%Q&Ki#XzIuqJ%~9 zc}2w@o?8Wa?XIUs_H@0@ay2g@5Y!t^59yP#_z$GqdvAP;H+>TsXy~LoQBv~_1Vlvre%(GeYvaDDQgOX4l>KL5O{BL#E#Ew} z`DJD{sf{>kR{vgmoTMcG`%^< z#rc)+Y4j~fPB%;j@(g#s_=x^E-qavoAig`EA`WyJGRnX=)mx_BAy4aT@tZpARfl3P z3uLkv*yW15hjK8_wWc?2YE7Bi51q>*AMy3HwjGLkt+v9RU!l5DR06h$dUjdxfc z!#D~C<^o%5(|>!&Q=N0;YRI)TZN9OqobMTK%$#ph23z~3$V%Y-r~CKbkbzF~^LD?n zqZq3!FD$5Q4jB6W5bs_eDI)K4uma_>l7XxVNp7u8B5Zl8Ue!g1=2$X2VEsVaZE}Ds zG%97#ZC28yks5uxG5dJvD@xLB{&I>|myKT6>x+R^7gq$B2Z2Xd(Q>V0d`CehPg$lk zw^ba(@}`7wTnV8JQSK1G6E0p0>oO-hQ2MM>*mrcAj}Y0id!nDFjq-=|SUU9PuK3ZfY_^zS27Rb`y>umcx@nqs#makOjBIHC)VY^Sb!MoM(;09QDmnY%?n3 zXkMZ)(?H1};q&ARmQRzz&t=uuYadt=1B&W`r<{7`N_oR}0D62D^|!{lG4)MA6B{As zq(KxvkwrQ=X8oPk+%d5HUFYXKIe7tPXxJL>?uf}MD@aXD5CW`I{02=E#7vT_Ua1^Z zahFJ@@oUS;LD6vf7kJzEGp-nCqPyi!{SP9{sL>eNo>{YGQC6$whSB{8>nc{<=?HZ>N=On(M-o480p= zlLZ5_LPm-$gXHw6eylyL#E;l~wO4qkr`t*hRQ<`4);PC2$9m>vx>WuCo=iNjUqtWg{nO>q?2>t3E>@eGQzWmlVFKquCwf`6cWcB zR!4e?=e)EI<9B-^tIY2`QvN4U3c_v_)7%t|_`++#dadcHYEt1f1PucKT$i)^if8Xm z!m{+N8z^zhf(aVLcyYWKc>k|*%EhGc-sgTxdnQe9>Ls>@Brt;YKfZ{Bdu$C%q)5Cv zc)TUq|6IMvQTzsAf6Ij_d@keAE%7~XCnICzj4tJ(thBSAwq67Ay$5$LxEudWXSj7c z>tcc0BH`zBTm-a{V$w|=)1|J9k2eaT;!^C_sqn2k&LBvJ78SrG0=!ydGf$#h$+LfC3RaSbH8C(COag9zjvvj@2R4rp+WS=kxsiWmxGP3 z?w&I{Pn9KlwMp$Y_mqK(n(a)bj z-6mPe?_OfCs2j!M(HSnOO4Ed~#ibcDVPGd!5;J~272c<1LfJ@ch8xCat(>A2GTs&N zvo?QGzr1M}{&BMtUqZV>&pd7-re))ejktL^l(C1uM7@^qtl_qNBYnkZpa_(h`n&8K z!nGfl?NF(d;pKDL3Jbdnd#*G0wbv4G*V^XX2_(z=#yI z73>U%90;*L{gwW~@``GpvaB>l;IlG01CGO z3EEOr;wKWQsLR-!gVbgy6_K*qVZq_LELLg$X>)5?&Z!`^&pXy1+k(JvA^~62ea*$P2Kj@%eiXv zVJ?nz_*f_1CFm0IEQUnlpp)(%{ZSm<l5E~USx#p`)BNCgZ` zM=Gt!2M!nRdYG+QPWf6fE!y{PX;cWia)gRGE1U|;)Yp=EW^FT}|EfXst6VQbN11*) z--%93rBC-xZ1uVFdEVwraP>HUO;7%s@e-)1&mwV7WE0_v*?O^wf)s{g z5}v%6J~v|@i3B6yHQL^Y3}nKA_%g1;&37R8t#nV*7`iwVj!%qM zD?pbj6`66YWQ+PF#aVjO+K(F$ZmY^tZ;_s)6ZpC;RnD&FhxBQiT+mGkSy^=e9;E zKA8){8;G_d^MG3`$uPS-LM9#J{Fi@I0F^~Ae(Lpui4e_+UC4ZtAnl_!^$C?uoqUm# zIczw?2L5@M!%64{dI^m34I0KMsa=;@%`V3Mlw;-mp~>0Xm^wAdZBle_x)Vx6jrub` zcSENGudna%DLmk{DBZ04Z^7AJr&B|VOk8L#Y*(O&9OHwN4s z-F}<$!-tT`LtiX4u!DCuWw+N2xy2WxXh0H(E~Y!Nxn1mfuVH07CNZ3UE}G5eu1&!S zawuq%WdNK6*uKrA0*EFqH%w(k@2Vrrj&X*Fu*+LMA`H>t+CBCdJC&Zt@&>*$xz znU%IG8depk&bL_`0Nm%+7Rhz=QPN563ZgS3_6A#}NbfTmL;jew?CZ0Gq8{?>mbR?m zaHEtZ{j^9Jkb(~wdyANSm`Y~}w%^e@{m9SuLQ5RC?mehf(3Ed&os#QH+&Zr6J2~U$ z<~H~-cufjMYung?;R{l79q{LlmlFN`OcD!z4zHit9F;a@V(Sk52yu{H(+#w1Wsh6E zWz}c>n^QAA&`;3}Jrx;s*b%UjqGz_8h&yk2zY&)V|Mf4`PoYK3gUYcF=EV>3H0#0Z8)p! zC_F!RN&v@=L`e0UPjs&jV#-{~Q`)NV%Paw8Nllfzw(Y$)Nw9Ym*0Jj9F^Pni~?5X9=r82^qQmwosohl|Ui@#Zlh zg^#w9bdAZ``d`S`ZsH3(PcYF&K^AaImL;UkoAAjG1j6YPCFSln{${cyI9fe*l>v-+ zoKFn3rKBp4m2S@V@F>?EZ^uCdR&o8s732%UqKB{gJx8)}lF6N>pCJqaVIF#_n|{ixPPgM8pk2<0;kZlIZ??%{m9HqI#N zZoKOqI@X`{UbpeY9Tv3rj=Z=$3yrJHw|qu({weB%KeYL?GMYc38$l5b5Doz3)DZslfCmwruuij= zYZ?(`5K%yo%3ZtEyL&=1JrKig?T*0^OU(zW3#{?B+cWLw5|}lEq;lPOn6S>Qa`1Pd zehOl7`j`UFK*bXy_m1Dj-7+0MYdr7H+Z+eUbQ)aL1KU=X#f5%5Wf`EQ>a3}@9R$Wt z=I!l|@FK;}_ul2@wGTXUdGhZ3r!?1J-4QS_Z+!nz*IfnenH6suoveWhb_Scamyc~U zqWd23f>dl1P=S`|*FG`c2qpgB~b3n^fhS`s*yF6DAzi6Rqgp#?5Sg+>57M z7Ux`MNadTgMoE@<3n`JiT&7Vp!;mGkZo^UC9*LAbD>$KYlyDV8e|hljk>ltG+H@9E zWoaQnykEL}1;O~bdlsJ*6v-I*gYub67+*rZFx@8Lw;P=1r%PT( ze~H-39i0cp7TfF4*lVNqw6P_(7a;D5=jGxA5i=kX@T1w1Hu^%K4L477Uc60YELDp5 ziU?AY>g;>-b`YQJoQQKNg;_}Dyouw9C--5gB_m7~tnKcS)z+&QliDk1+)P9e{W7yrn4K%AZc?P=SCwYOhI!EV+*QeP_iPd6ADEq-5_ zzo4gcgo7g=nfFrhVW9!;oW*o+to}z^+`;bFt&yL>5#2@idt$3fBVd>J={5v6o8K%0 z==!$#&(zNVxN=#2$WyHRZ?%*CBTPZwif}sc=kbzDa0EclM>3n+0_1ED0Q;}6zvgF( z-2o~h!XffXneWhx7`A`N3!hvQfSOokt$^uuYrzE~FFI2vWjj!pe@RKXpljXT4Lbi( zQh*vRvIo!{ku0W}(Uzp#7s?e;{>LSdU3=gH(&jmiiPzDTFApG+A?o7D`cYCxSBT*S z-MTS3f~&sc+@}fO!gPu9&OSX^E-9gZN@kqz z>1pB}cKL&-qRCuMbQsm_ejeTysQ-u8L->xz>RfP|TS`;*^%b5M8WRIF@l8UsJ5Ir= zGnLy@A_6ZulnbNX%;k=~7zJsUJgFo(>tRRua1GGpNo*Dc@aRots$ZmBc4b3f2_IN417JqeOVTjxL-fWCIzmwbKWm&j*+@Wg-MgHonsa&3ni+;N75C5MEC?n< zR54@kUxvx_lo&5C`rs%|Jfj_ZfxDJbJ!WsiJKk(&wbqew{d@W zgQ6!c-7qm01gV?o(pF1f%c#eDYw3Xeps?4DylnnOUb&tgb5+FoFk_Oqcn#i{=*F-{ zqa`2xotGYSt&WjQtWw>hRHpvl(PIgl&)h26-jza}Hj~sA;E&g1yRUhYR)~W+9B1v#);mNt-&m28y@kZ8!;O`=hw^aZR?IR{CfjpotG7m zYzU(%q9cr_gtp&Z;un`m;}osy@s@@M946b8cpwLJ_Z}cT!=UIlSr2wEv-Uzx^;QLq zotj4Fcq}7n&HHpe!wg8812Vs7)|a0iTEv&9z~VR$GX*U^Z49f=|EZhxcdH+!z_<}K zATV!m^9EuyB5CZvJh2zdP{ZO(lKaz>i@o=C6qH3Sd&fsXccOp!3{cDUity{_rLnREF{En&@%UbX0z`zfRx@6UPn zz?nO7qc6OO$1~Wq7L@#EK!LA>HplCN=|g@+yymc$JZ{VN%(%b?S4mziY+Kq0R|Ofr zS#0e?b@IMyFg?0XRyV4*+{9{x4X4Yh#{ub>(Ujlpxehl(2iB|t>KJP&Y8dI)z0Eq; zKC$~|M!WbvlwPoaRwlgCgI;!_3UFO`z{ycEIS)7i*u4Os;Y4l^^pX>MVh;I%c8I@E zO<=nYmU%5?PYS{8*22!c;d??RddqYYXdZ48inr3O@65)toA6U*#buSdint?f7;)$9 zKeKZtm~X$gHVNEfKo3)DH#{GHb_^WC$zhrclhv(0)I$t&cK#8eR~EH7CB8OS{6~&4 zVp_5+u;-GP`}|v|gJ^u!c+!n;M}i7ZX2XYyrhHNG;g%A-+k zaJB6BvQVJ!Ru@O;!dxtOV&ig$@kN=U5I)rd9w5i8(e29%(i@&S==xB;dxVXfhGamq z#2c-VR_fz7i3%xUshcU|HC&yx4!}0;B?Q8=9LKXxQlMw!9mke+}g@@ZN2R%?GUZz^-9Zk3AMX4S2GGO4jsrIX;=3Tep<}LG>r?aQitv5RwJ#I`T z0}mJ-p1kOexL4eDB<`SbcMEaH(Cr|me}QUbs=^rkB5lrQWm)Wc^5tHp0xNz)J@npB zYqQ4(Zb6^L(E-h}ZTg+RC@K{icyJhNIJEtz2KXYMnd1!}@%&+i$VHNV?;+HklKut< zpHlu-ZWb~;bx+$AOH%_^n!%q&Q8?29neBK9pyw-HI}s}9MRnCfV)D42~H zdF64M5YgSC8+goI1Vt`@@oFf{Iezzcw-3P7x*MKbCR$)AtG>IllUfj{zkX0T9$n(sX z#yHC4G}n2VqXB=j^YgeWt4}+}5Q#+(30%2#P#=qGox{BoD5OX4y!bVioGjqs;huH8 z{x$xK7#K)C0?D?xSKRb-)9Q1QzP7JKXRK-dP0^}g9vj0}1tn!Yisy0HYmExA(6H2S z$rH)ED0WJMZ8lwGH~gX8&lV8{CFyh+z(J4V{|jseD8EzfzHg7AkMk@qfq@O;JHnLK zvYxrFyETPrC|umz@3GVB#5Y1795B$f_P~?s@QL)&VbqLE+8FTyGjeGzATi#(WGQ`? zw*Ycnb7JC)gaCyVZz`-g)|aRusXbxJI|l0cyKZ079oPztz`-s`O(&fC!wLk|%Q-AM zrw@QfD>j7xi&?@58k(^{+PGt}sVXJV&^@pTkaEEv2^xs1$lbjdKq1cq18aLdA*%o$ z)bW-QBlyo%S>5?wEYep@RXCd>DPQ@)=_=PLi>^xe*PI8JRGR4Tn8nbc#Y!eK)m8-J z^zoV0zu>C7Ei6||8ZOf*`gq+%Vy2E$LF8X!vB)CR)iK}W7Pf`VYoTeT3<%J4bXt>x zlK;V3{J3+3313FV^(~miE2{pFioe>j4NxM0UNZo~je;DL6i2M-${?l-o99#W|C}!P zg#2J%UR)1QI?$Y`9(14s{ahn^0+|zBBmdc?{*}}&6)O&Ne z#Jj`O2lzr(^<=(qUZ1P?jyDz@{r&5^KA*&PHkIu}hfC`^o6^zgy91l*lh40AgGz4a zq|g7f@?YRGcJ+)oir(FLR9NZ?6{3A3I3&Kfv81ImOme;H!+c2C@P?w7_piJ%9oqYCt9G$ZH;SjlR+yh@K22i zmqZ~kUlP=88aD4o!(AGx8yiofOwTPILh+S?2hzOySQ6;r&i zcImIx0(HULB0IvzRbULWB0q5M=WxK5gZx{07cWyMxwNromxM(eo2%%axz2f8Xq-#d z(V(sJg)IF8w%vD`>3yHE9XpZM^)m-y;L348@#1}b$(!d3xS^UGSOg)_4|qOEptMX> zN?(4{j!=Vin4GBVw!DiF);QX-rB8*$ML;NLN7eCsrR6oHvjBZ~; zO#2m#626tAy@Nm$_Tc1EHFzhXKd{hW%FA@jI@WsG;+D#?ZCP=7UPO!>ORVucr}4|u za~S2;96n9~Tq$ozpgF9_z9YUe+J}#w%`P^=zwgEuR^boz*d&{k{p4%1%E?C-Yo7nw zX~xI0HvdxAMEEA}hq+mEiy1x&ttp2A8XU~r`LQ0TqvYu&qI?tV@y>z|ZRDXOtfFog zS1`Y#DmI*2*BQVp+h+qJX0+5C5k(5&Z1lD@Pg|2??@K0iRv5%6=RFP}7IMgUXH(eg zV##+&ps=;bv{ErJYYz)(ymMIa1@U~^W5fI^9iLskEw7rIeaRMuQ-Aw({)ZeRb~dJk z%sd!QjYi5&=6^hn=oh>F?~doisUOl|wdH(@qWRA*#+~~Na-1ld>x%)L{vFW%Qeg(l zo|wDp&bfuZoPG-7$$9)woT){$_Id3b2eT{@cjDT2n}YUUsC1x4!|>EmEtlmRzXHN)neem~{mGm>0Es;-t%rwTzz zXa0aRMVU`9pGfcFfTh4WGZXJ|DYY5-0a2&^rm=DpRv?7E4I|yZT$CD7pOaBfj&_s+ zT0f(KhRvfkg&Dn$sB4EmJjm7yN)&;vOk~j5X$gzPyvPW`%ipxw_mJ$3jcqJK{((3 z;f%Ty7%7*dOS`)sZok!=-i~ooCK{3qNu>XK5lr{x&Ew4}#OE0Gcf#HEAJ1sEWQJp% zoC0HY7+Iv9bRzRQEl7X-cM0+462gm9m;fX&ob4aFHa6A5qR{s-v zJm0N~8e5Fgt;qf_{9lL0rNTJKqn z(2(4Kc7J>~&0F?OpcJ?7j%EL!uCz#t1$Q_>gKvCyY1Nyae+<>?kWm48!)UO*VZd>b zs_CaILjX9%-bZmj&UHVxjiNMSee5@83nOP=otlia`BS z$SCK3_k}%G6wsjl_ZsKKo0k~Omh7Jo^votD$!!06W8mH{O8f7osshsJ_s-0EkHE80 z(eTtD>Ih&|AT`m8ScF_&gcmYZ&r&c#|4vOQ#9~dQoW)T;a#>G>S=K0j%{G_XE44GU ziI*0}_>G+HLVqjk1Pt9s1oRqlOp;taaw}&z-Y)n^>^2NNBt@U;Z8X`P8m#v?3O_l? z;GXrfLDsHkpoG`Yy0^h~8e3G0;!C%t{4%rXWgkntGeEDcsJU<(`Xt>nJNK)YID#D2 zW$Ns%ufJ_zlYN_qd4;CP2Y`*r{Pd5(jC3-0)XN)2h+bqd(j!Y7ckAdNYa){O)eWt&vhlv!KdVpkbKF#N1DrJ70 zRWnX9D_wL<9~TO}H`^!L!#ylEJnz1GV6h_Zb>)s;j}mHJ+2B02Xxv?hss)&s z0w%@ejXqtbxxs5dvi%;FCAiqNl2f7ncU&8P4jjlxTYu=R*_}R47spin=^;-qAy$OC zY27N&gj3B9D{VZMYlj!-YV>>a$9fKheU=P%7)6Il1vE*qfIafJDeAzE?QdrtTJ2AS z84bd{gTLOEJM(2(It{vPx0yXnuQ9@sM!jk#o69bvQ-#1nj-r~;?obG^eif~w*l`pZ zb24hK(tJYBbA=pBIS&)$dJ+Awk|yS8)et`3B`Cb81iGFJBCr6loLnI{+GB;U7sXd- z$ehz%_l>oPP#=2$P`f6e+M&oqAk4&t7xn7a=&QnbSh%nUJhH|8vq53M`9+$1$1xGk z7_k#or(cVHbXWqLP|Gj}A}PT2nds*!c1z7|1rzz7%cnZr?Q>8h?4`1~%Eft=8@B>Y z;2s`8I{2EhTX(S)Ixw0Rhgu6eGUMFVjCa+m#KSk7v6aCsU5>hiwmNys%jMhMf4;ft z**)_^r_W%4M`6)}8d1*4Lsao`;d(44u*E)-m1J=Uv!j*59DQR{nBfS}Lm7WN#JHo6 zurW)_D&QNe_@i}KQon1AAnI+EF3wTGU%)}4xY(yLB39j;pb00M_={gq2U;#xhrpv5 zyLmb2os^!`hogCUjT{`*`Y~veT ze9b*5bxRiPETUo`IOr0L^+^3NISU+s)f(xr-C*Tz%40Ia{e>}6mZp{qz;0kJ)}`3# zQ(;fw%{dC=(%`>ZC*cZJbf7zXjs5Aj|7x4tjsir&=`PpvK21$(pj6$lqX?cbMx>j-|u=vG2Q+UfJ{jd)mXA6G+lnKn^5`zbUkTI7EP?|9^EMqzV7b zExYyWT|c!fy_wTE@N5!Yw(}-|#iiPudnz}{Y~F%JigBhlrwzBd6!Lq#9uZj#ILmxm zD$B(G+R7EpUuJACg@~@Y_?$i`n==3E&l025-nGL#PCjp;{ZHxMl&o~veMWlVHL)Xw z4!13-Nu0i}w;#qR*|(n;Aq3OTe6GrR1NB+hkQ(K))HS0|Oi*@SlG86$np(v%MR$)4 zW-y2p)Pd83RS_p9#6|DX-L{jiIgCmP@DrIhQrV6EK#8I8Afsh2h8M$ zzGH%FuJEb*u9sE|N4cRlA8Ur|w9w|pbw=ureTmVpzyc$PnnBc{H6y_R`P%uKcit&A zPOlJbQS~@2l*rF+PYyIU2ri+YrBqq!q@ZpmXH*fVRh`7Z^_{NjBYn``+N3K?xWt}YyH`yrHKH%abZBw0a74X)(1 z_S7eBG7Np$SY9>D<37rdDekP%Yv{A3s2Bus8MkW$9ZvYf+VFG}o5!DiP(k#(ry>2h zm^E~v9HJ>4S+FhlkML;t7>C4s5>hz3&k`L6(Wu+%64qFywS+{y_#`*+f>SZvYUMQE zAeH%E=LoRPup3ht_9QVKN+=eYSL8kLag9V)-Mbiq1)jaXrR8i74K)d0k75br(_jt{ z7r}|YrSGA*MMg zsgEpT(+eHSfuDI@n|dXqiX2Sjs1LAD2%{Yfe+X%se%XjrC)6(ywS|7@20>l@CarK% z1??RoNgmEn12e7`>0Dx9RSO=`s!@0$sna zIvoCYe3*OKXl=ogqb4JI!{+Vt%*Bmldd4Yy%tAKh}=~LxI%DM4(iwM6Ys+mvkjAbsSU0Z zTfy>aB1?@S!*8mhW2&b&g_Q`N5eMC}r15A)`#c)f3Td1*FQdo(NWlk!#o?^Z2tWG* zS$Z8AhrhPt`lRDx(9i$G_)q`ko+GahsBU6vOKX|y*BPVuA>cK#FLC1(Q?7uKI639g z9x9o33uy*W3$j>^vSb6!K?cfg{Y-j;zBP%Go;&%a+gLs$^x-~6cxy-Ve4WEN_t>w* zcinllg)0VS-B#|YDu{xbVN36Z9Z29ti+<`@QKcc$>o3iS68TlI>cyFB zC)dpK@%791b%}AzCULK}fg#pK-`%}s`zZ|h3kiNU`cAN&AhUv>_3+jurF$Kz71{V6 zHzwo9x9DDf;ftYIQ$vw3n_JZe3m!SO| zLb#f@b~Z{Bd)d|R_YLCh7!$@od@P-C59NKx!+HwP&W>#I$k3J*-Mb1ADB1XlJN3k~ z7ijIROth=8+_P1Rj-feCxh4@>`uVHT8BgSZ$bOjd2wAy!x`> zhOFP)I}7gh%I8G!IP3;xW~S$YABV80&?%wgPVGil1$0EN@UZH<;d!nM0Q4%hp7(A$ zDCfhpHdmj$w5Ix_+ze?rF6B}*^n22>*mY_cZJ=l8w|ZHwN-VATvmd^R&4xh4uL_*7 zB3VH+19%L|1Y*_nibVnj-AOBUGRD*p<8I)4AP?P^+OT0_ug7;!f|n`6TG{@-5ifcl^8kQ%j&8HHLUra zH1Co1l}C#xl!J$kpIWm|h7UDf&~Fy}3G^@TRX=7J!HJvYPjM z%;Z0xqj(YaOI~SsB0<~@uMxrbedkov`_SvRz(HSw9CUno$t(h%Y*1>uSRi(zUA>iD zoaUBzYVJYIeMq#;HFn3?^Ou;fINMqbkY&C?Nuy*gi4db#-BkLO#&vYq=6F?m50ql> zHs1X<({ z5S=9LiQx##IpnGs%zQ??%v#UKF=y0)NF$I}6U;vxL?b@x2|Mfw_b2X~sIPH%Ly!EjLB)ZvA>q6^w1xK2|sgtPhiy0rkIj$ty0+c_m(R`Y@W;CmW^|t zfP0e6lRA|>CQ>^E&*aCFfM)Wn8UZ(x#j4SR+tc?@hW~Wpg0kc&j+xWhT!$#<-Fc$O zzio}mAaztc4#AK z^g9@E#DpaKJJ;{i-P5tEYK45_C!$9gn|VQ;|6jqH5UbbTFgxzq>6e zQcZF+`)`u9J@q;7$4>vuqD(4Nf?qYx>1b*MYDaWCuzV|C4&YCiwzRb(Z~qZDwj10n z^0nG#qSMM=s^$pCw(EL<3iGE=8f5z=JMIEt2?+mF-UZ$o2>%m7flc)@3y`{N`*Dbg z__6k#EgCan(@&Y;%}xWP3o6k1oFgLXE8+th(Il?s8Z5#Ds2(Dt#*^;MudCe}lN@n?A0F8}$*v;ratp&J}Cx2gjQ3s$lwb;^nG_dZzqtUF1G z-Y)XeRZqngT64;Hd;Kw z%M!EJv?FDWq&_bV3fFe_<`O)5-9tYyHFsDnznMKL-rL57N0NB0fZGiuHtw=IWp7?> H>T~n|p^HMs literal 0 HcmV?d00001 diff --git a/demos/nightbloom/README.md b/demos/nightbloom/README.md new file mode 100644 index 0000000..583349a --- /dev/null +++ b/demos/nightbloom/README.md @@ -0,0 +1,134 @@ +# NIGHTBLOOM — a garden against the eternal night + +A vertical danmaku shooter on the PocketJS deterministic runtime, in the +grammar of Touhou's *Imperishable Night*: the player pilots one plant at the +bottom of a portrait playfield, the eternal-night youkai horde descends from +the treeline above, and the piloted form switches mid-fight. The night opens +with ONE form on the roster — the black-and-gold moon cat — and wakes the +rest as you play; when the pilot dies, you switch within its last breath or +the run ends. The art is PixelLab-generated and committed. + +On the 480x272 landscape screen the field is the classic arcade adaptation: +a portrait column in the center, HUD panels on both sides (the night on the +left, the roster on the right). + +![NIGHTBLOOM: the NIGHT SPARROW DIVA's first card](../../assets/screenshots/nightbloom.png) + +*The witching hour. The NIGHT SPARROW DIVA opens NIGHT SONG — WANDERING +CHORUS; MOON PRIMROSE — a mountain of abs with the sweetest face — holds +the pilot seat while the moon cat and the sakura rest.* + +## Play it + +```bash +bun scripts/dev.ts nightbloom-main # then open http://127.0.0.1:8130/?demo=nightbloom-main +``` + +| input | keyboard | does | +| --- | --- | --- | +| d-pad | arrows | fly (8-way) | +| CROSS (hold) | X | fire | +| SQUARE (hold) | A | focus — half speed, hitbox shown | +| CIRCLE / R | Z / E | switch to the next living form | +| L | Q | switch back | +| TRIANGLE | S | the piloted form's spell card | +| SELECT | Shift | the garden codex (forms, foes, laws) | +| START | Space | start / pause | + +Survive the night: nine waves across DUSK and MIDNIGHT, a midboss, and THE +NIGHT SPARROW DIVA's three spell cards at the witching hour — and every +card change is a METAMORPHOSIS: each boss phase wears its own 64x64 +transformation portrait (stage dress, flared wings, a phoenix final form +that grows 52 -> 58 -> 66 px), announced by a flash ring and the boss's +own cry — the diva chirps, the umbrella clangs. + +**The roster wakes as you play.** Only the CATNIP KIT answers at dusk — the +other two cards show `?`. Ascend once (stage II) and the sakura SAPLING +wakes; see the midboss off and the mountain answers. **And no pilot switches +itself**: when the piloted form dies the LAST BREATH opens — a 1.5 s window +to switch to a waking form (O / L / R). Miss it, or die with nobody else +awake, and the night takes the run. + +## The roster — three forms, three answers + +| form | wakes | shot | the ability | +| --- | --- | --- | --- | +| CATNIP KIT | at dusk | homing orbs | a black-and-gold moon cat, a white moon waxing on its brow; **dances with death** — wider graze ring, double graze glow | +| SAKURA SENTINEL | first ascension | petal fan, true damage | **the healer** — every damaging petal mends the most wounded waking form by 1; her hits are soft on purpose | +| MOON PRIMROSE | the midboss's fall | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | + +Spell cards double as bullet clears: NINE LIVES (homing burst), PETALFALL +(clear every shot on the field), MOONRISE (+100 glow to the whole waking +roster). + +The avatar is alive, not a decal: it breathes on a tick-driven bob, leans +into its strafe and **mirrors to face the way it flies**, and **grows with +its stage** — 22 px, 27 px, 32 px, each stage its own portrait, +pokemon-style (the hitbox never grows; what you dodge with is always the +little white dot). When a `?` card wakes, a slanted rainbow shine sweeps +it left to right, trailing colored dust that thins with distance. And the +world never stops scrolling: a foe you don't kill rides the drift off the +bottom of the field — nothing parks on the screen forever. + +**Two evolution laws.** Forms grow by their own work — glow comes from the +damage the piloted form deals, the motes it gathers (auto-collected above +the high line, the PoC), and the bullets it grazes — and ascend I → II → III +into wider patterns and deeper pools. Foes grow with the hour: DUSK sends +stage I, MIDNIGHT II, the WITCHING HOUR III. + +## What it demonstrates, mechanically + +tidelight proved the deterministic runtime on a branching story; NIGHTBLOOM +proves it on a bullet-hell: + +- the battle advances in **fixed 1/60 s micro-ticks**, `ticksPerFrame()` per + host frame, batches aligned so the tick count at any virtual second is + identical at every `simulationHz` — **the 2 Hz world dodges the same + spiral** (`?hz=2` on the web host to watch it); +- danmaku pattern math runs on a **quantized sine table** (1/8192 steps), + because raw `Math.sin` is not bit-specified across JS engines and a spiral + must replay byte-exactly on every host; +- press edges land on the first tick of a frame's batch; held verbs + (movement, fire, focus) read the raw held mask, whose level track goes + true at the same battle tick at every rate — hold-driven tapes subsample + exactly; +- waves draw entry slots from one seeded xorshift32; float fx drift by + battle-tick age; the phase augury arrives through the effect shell + (`backend.ts`). + +**Sound** is an output, never an input: the engine emits `SfxKind` events +(the hit thock, the kill pop, graze pings, spell declarations, per-boss +transformation cries, the dawn arpeggio) into a host sound sink. `sfx.ts` installs one where WebAudio +exists — every voice is synthesized from oscillators and a deterministic +noise buffer, no assets — and resumes on the first key press per the +browser's autoplay policy. The headless sim and the PSP never install a +sink, and the simulation is byte-identical either way. + +`test/nightbloom.sim.test.ts` drives two tapes through the headless sim +host: THE MARKSMAN, a full clear (~163 s — the catnip opens alone, the +night wakes the other two, every form flies and all three see the dawn), +and THE SLEEPER, whose lone catnip falls at ~34 s with nobody awake to +switch to. It asserts repeat-identity, chaos immunity, +strict 4 Hz / 2 Hz subsampling of both tapes, cross-rate byte-equal outcome +screens, augury effect timing, and the exact score / graze / kill / bloom +ledger. Wired into `bun run test`. + +## Content pipeline + +Same contract as the tidelight demo: every sprite and backdrop is generated +by the PixelLab pixel-art API (pixellab.ai) from the seeded manifest in +`data.ts` — `gen-assets.ts` is a no-op unless an asset is deleted or +`--force` is passed, and committed PNGs are re-encoded through the pak-safe +canonical subset. + +```bash +bun demos/nightbloom/gen-assets.ts # generate missing assets +bun demos/nightbloom/gen-assets.ts --only=p-catnip-2.png +``` + +Evolution stages are `init_image` chains — stage II derives from stage I, +III from II — so a creature keeps its identity as it ascends. Stats, prompts +and sprite filenames live in one table (`validateContent()` runs in the sim +test), and the bosses wear the stage-III art drawn large. Enemy danmaku is +native dots (no textures) — player shots, mochi and motes use the sprite +art. Auth: `PIXELLAB_API_KEY` in the repo root `.env` (gitignored). diff --git a/demos/nightbloom/app.tsx b/demos/nightbloom/app.tsx new file mode 100644 index 0000000..0919564 --- /dev/null +++ b/demos/nightbloom/app.tsx @@ -0,0 +1,746 @@ +// demos/nightbloom/app.tsx — NIGHTBLOOM: a vertical danmaku shooter on the +// PocketJS deterministic runtime, in the Imperishable Night grammar: the +// player pilots one plant form at the bottom of a portrait playfield, the +// eternal-night horde descends from the treeline above, and the piloted form +// switches mid-fight — five plants, five shot types, five spell cards. +// +// On the 480x272 landscape screen the field is the classic arcade +// adaptation: a portrait column in the center with HUD panels on both +// sides (the night on the left, the roster on the right). +// +// What it demonstrates, mechanically: +// - a bullet-hell simulated in fixed 1/60 s micro-ticks, subsample-exact +// at every simulationHz — the 2 Hz world dodges the SAME spiral; +// - form-switching as the player verb: five plants with distinct +// attack/defense stats (homing, pierce, true-damage fan, armored tank, +// moonlight economy), each evolving I -> II -> III by its own work; +// - enemy danmaku as native dots from a quantized sine table, plus a +// midboss and a three-card final boss with Touhou-style timeouts; +// - graze, a point-of-collection line, per-form spell cards that double +// as bullet clears — all on the virtual clock. +// +// All sprites and backdrops are PixelLab-generated pixel art committed by +// gen-assets.ts. Every class is a FULL literal and all copy is ASCII (Inter +// has no CJK). + +import { For, Show } from "solid-js"; +import { Image, Screen, Text, View } from "@pocketjs/framework/components"; +import { onFrame } from "@pocketjs/framework/lifecycle"; +import { + AVATAR_SIZE, + FIELD, + FOES, + FOE_ORDER, + PANEL_L, + PANEL_R, + PLANTS, + PLANT_ORDER, + POC_Y, + SHOTS, + WAVES, +} from "./data.ts"; +import { createNightbloom, FX_LIFE, type FloatFx, type Nightbloom, type PlantState } from "./engine.ts"; + +// --------------------------------------------------------------------------- +// Title / endings +// --------------------------------------------------------------------------- + +function TitleScreen() { + return ( + + {/* 256x128 scene drawn at 480x240 — a UNIFORM 1.875x (stretching to the + full 272 screen height would squash the moon); the bottom 32px is a + letterbox band the legend sits in, tidelight-style. */} + + + + NIGHTBLOOM + A GARDEN AGAINST THE ETERNAL NIGHT + + + PRESS START + ARROWS FLY HOLD X FIRE HOLD [] FOCUS O SWITCH FORM + {"/\\ SPELL CARD L R SWITCH SELECT CODEX"} + + + A POCKETJS DANMAKU + EVERY NIGHT IS A TAPE + + + ); +} + +function EndScreen(props: { game: Nightbloom; win: boolean }) { + const g = props.game; + // Only forms that ever WOKE count — a locked card never fought. + const awakened = () => g.roster.filter((r) => r.unlocked()); + const survivors = () => awakened().filter((r) => r.hp() > 0).length; + return ( + + + + + {props.win ? "THE DIVA FALLS SILENT" : "THE GARDEN FALLS DARK"} + + {props.win ? "DAWN BREAKS" : "ETERNAL NIGHT"} + + + + + + {"SCORE: " + g.score()} + {"GRAZE: " + g.graze()} + {"FOES FELLED: " + g.kills()} + {"GREATEST BLOOM: STAGE " + g.bestStage()} + {"SURVIVING FORMS: " + survivors() + " OF " + awakened().length + " AWAKENED"} + + + + START RETURN TO TITLE + + + ); +} + +// --------------------------------------------------------------------------- +// The field +// --------------------------------------------------------------------------- + +/** Two drifting star layers — positions are pure functions of the battle + * tick, so the parallax subsamples exactly like everything else. */ +const STARS = [ + { x: 22, y: 30, layer: 1 }, { x: 61, y: 120, layer: 1 }, { x: 104, y: 70, layer: 1 }, + { x: 146, y: 180, layer: 1 }, { x: 178, y: 40, layer: 1 }, { x: 35, y: 210, layer: 1 }, + { x: 130, y: 236, layer: 1 }, { x: 88, y: 156, layer: 1 }, + { x: 44, y: 84, layer: 2 }, { x: 96, y: 20, layer: 2 }, { x: 152, y: 130, layer: 2 }, + { x: 14, y: 160, layer: 2 }, { x: 186, y: 220, layer: 2 }, { x: 70, y: 250, layer: 2 }, +]; + +function Starfield(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + {(s) => ( + + )} + + + ); +} + +function PlayerNode(props: { game: Nightbloom }) { + const g = props.game; + const sprite = () => { + const p = g.active(); + return PLANTS[p.kind].sprites[p.stage() - 1]; + }; + /** Pokemon manners: the avatar grows with its stage (the hitbox does not). */ + const size = () => AVATAR_SIZE[g.active().stage() - 1]; + const blink = () => (g.invuln() ? ((g.fxTick() >> 2) & 1) === 0 : true); + /** Idle breath: a 1.2 s triangle-wave bob, a pure function of the tick. */ + const bob = () => { + const ph = (g.fxTick() % 72) / 72; + return (ph < 0.5 ? ph : 1 - ph) * 4 - 1; + }; + /** Lean into the strafe, danmaku-style. */ + const lean = () => g.lastDx() * 9; + return ( + + + + + + + ); +} + +function FoeNode(props: { game: Nightbloom; foeId: number }) { + const g = props.game; + const foe = () => g.foes().find((f) => f.id === props.foeId); + return ( + + {(f) => { + const def = FOES[f.kind]; + return ( + + + + + + + ); + }} + + ); +} + +function BossNode(props: { game: Nightbloom }) { + const g = props.game; + return ( + + {(b) => { + const phase = () => b.def.phases[b.phase()]; + const size = () => phase().size; + /** 0..1 metamorphosis progress (24 ticks), -1 when settled. */ + const morph = () => { + const at = g.bossFlash(); + if (at < 0) return -1; + const age = g.fxTick() - at; + return age >= 0 && age < 24 ? age / 24 : -1; + }; + return ( + = 0 ? 1.45 - morph() * 0.45 : 1, + }} + > + + = 0}> + + + + ); + }} + + ); +} + +function EnemyShotNode(props: { game: Nightbloom; shotId: number }) { + const g = props.game; + const shot = () => g.enemyShots().find((s) => s.id === props.shotId); + return ( + + {(s) => + s.kind === "mochi" ? ( + + ) : ( + + ) + } + + ); +} + +function PlayerShotNode(props: { game: Nightbloom; shotId: number }) { + const g = props.game; + const shot = () => g.playerShots().find((s) => s.id === props.shotId); + return ( + + {(s) => + s.kind === "petal" ? ( + + ) : s.kind === "banana" ? ( + + ) : ( + + ) + } + + ); +} + +function FxNode(props: { game: Nightbloom; fx: FloatFx }) { + const age = () => Math.min(1, Math.max(0, (props.game.fxTick() - props.fx.born) / FX_LIFE)); + const cls = () => { + if (props.fx.tone === "lumen") return "text-xs text-amber-300 font-bold"; + if (props.fx.tone === "ward") return "text-xs text-cyan-300 font-bold"; + if (props.fx.tone === "evolve") return "text-xs text-pink-300 font-bold"; + return "text-xs text-red-300 font-bold"; + }; + return ( + + {props.fx.text} + + ); +} + +function Field(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + + + {(m) => ( + + )} + + {(f) => } + + {(s) => } + + {(s) => } + {(f) => } + + > 3) & 1) === 0 ? 1 : 0.4 }} + > + LAST BREATH + {"SWITCH NOW O / L / R " + g.wiltSeconds() + "s"} + + + + {(b) => ( + + + + + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Side panels +// --------------------------------------------------------------------------- + +function LeftPanel(props: { game: Nightbloom }) { + const g = props.game; + const phaseName = () => { + if (g.phase() === "dusk") return "DUSK"; + if (g.phase() === "midnight") return "MIDNIGHT"; + return "WITCHING HOUR"; + }; + return ( + + NIGHTBLOOM + THE ETERNAL NIGHT + + {phaseName()} + {"WAVE " + g.waveIdx() + "/" + WAVES.length} + {String(g.second()) + "s TO DAWN?"} + + + + AUGURY + {g.augury()} + + + + {(b) => ( + + {b.def.name} + {b.def.phases[b.phase()].card} + {"TIMEOUT " + g.bossCardSeconds() + "s"} + + )} + + + HOLD X FIRE [] FOCUS + {"O / L / R SWITCH /\\ SPELL"} + SELECT CODEX + + ); +} + +/** The reveal's particle tail: offsets grow quadratically so the dust is + * densest right behind the shine and thins with distance; rows and colors + * are scattered by index. A constant table — the animation is pure + * translateX off the reveal progress. */ +const SWEEP_TAIL = Array.from({ length: 12 }, (_, i) => ({ + back: 5 + i * i * 0.55 + i * 2, + y: [4, 22, 12, 28, 8, 18, 26, 6, 15, 24, 10, 20][i], + cls: + i % 3 === 0 + ? "absolute w-1 h-1 rounded-full bg-pink-300" + : i % 3 === 1 + ? "absolute w-1 h-1 rounded-full bg-cyan-300" + : "absolute w-1 h-1 rounded-full bg-amber-300", +})); + +function RosterCard(props: { game: Nightbloom; idx: number; plant: PlantState }) { + const g = props.game; + const p = props.plant; + const def = PLANTS[p.kind]; + const isActive = () => g.activeIdx() === props.idx; + const wilted = () => p.hp() <= 0; + const cardClass = () => { + if (wilted()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-slate-800 bg-slate-900 opacity-40 overflow-hidden"; + if (isActive() && g.wilting()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-red-400 bg-slate-800 overflow-hidden"; + if (isActive()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-amber-300 bg-slate-800 overflow-hidden"; + return "relative flex-row items-center gap-1 p-1 rounded-md border border-slate-700 bg-slate-900 overflow-hidden"; + }; + /** 0..1 progress of the rainbow reveal (48 ticks), -1 when not playing. */ + const reveal = () => { + const at = p.unlockedAt(); + if (at < 0) return -1; + const age = g.fxTick() - at; + return age >= 0 && age < 48 ? age / 48 : -1; + }; + return ( + + + ? + + + ? ? ? + + + + } + > + + = 0}> + + {/* the slanted shine head */} + + + {/* the particle tail — spacing widens away from the head */} + + {(pt) => ( + + )} + + + + + + + {def.stageNames[p.stage() - 1]} + + = 1 ? "w-1 h-1 rounded-full bg-pink-300" : "w-1 h-1 rounded-full bg-slate-700"} /> + = 2 ? "w-1 h-1 rounded-full bg-pink-300" : "w-1 h-1 rounded-full bg-slate-700"} /> + = 3 ? "w-1 h-1 rounded-full bg-pink-300" : "w-1 h-1 rounded-full bg-slate-700"} /> + + + + + + + + + ); +} + +function RightPanel(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + SCORE + {String(g.score())} + + + GRAZE + {String(g.graze())} + + + {(p, i) => } + + + + + BANANAS + + {(i) => ( + sh.kind === "banana").length > i ? 0.25 : 1 }} + /> + )} + + + + + + + {PLANTS[g.active().kind].spell.name} + {g.active().spellReady() >= 1 ? "READY" : "CHARGING"} + + + + ); +} + +function ToastStack(props: { game: Nightbloom }) { + return ( + + + {(t) => ( + + {t.text} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Codex — the almanac of laws (SELECT) +// --------------------------------------------------------------------------- + +/** Text does not soft-wrap on the native host — the book is typeset by + * hand into short lines, and data laws are split at a word boundary. */ +function splitLine(text: string, at: number): [string, string] { + if (text.length <= at) return [text, ""]; + const cut = text.lastIndexOf(" ", at); + return cut <= 0 ? [text, ""] : [text.slice(0, cut), text.slice(cut + 1)]; +} + +function LawLines(props: { text: string; cls: string }) { + const parts = () => splitLine(props.text, 32); + return ( + + {parts()[0]} + + {parts()[1]} + + + ); +} + +const MANUAL_CONTROLS = [ + "ARROWS FLY, 8-WAY", + "X HOLD FIRE", + "[] HOLD FOCUS: SLOW + HITBOX", + "O OR R NEXT WAKING FORM", + "L PREVIOUS FORM", + "/\\ THE PILOT'S SPELL CARD", + "START PAUSE", +]; +const MANUAL_BREATH = [ + "PILOT DOWN? SWITCH WITHIN 1.5s", + "OR THE RUN ENDS. NOBODY ELSE", + "AWAKE MEANS IT ENDS AT ONCE.", +]; +const MANUAL_NIGHT = [ + "SURVIVE TO DAWN: NINE WAVES,", + "A MIDBOSS, AND THE DIVA'S", + "THREE CARDS AT THE WITCHING", + "HOUR. EVERY CARD CHANGES HER.", +]; +const MANUAL_GROWTH = [ + "GLOW COMES FROM WOUNDS DEALT,", + "MOTES TAKEN, BULLETS GRAZED.", + "STAGES I-II-III: BIGGER FORMS,", + "WIDER PATTERNS. ABOVE THE HIGH", + "LINE ALL MOTES COME TO YOU.", + "SPELLS CLEAR BULLETS, TOO.", +]; + +function CodexManual() { + return ( + + + THE PILOT'S MANUAL + SELECT NEXT PAGE + + + + CONTROLS + {(l) => {l}} + THE LAST BREATH + {(l) => {l}} + + + THE NIGHT + {(l) => {l}} + GROWTH + {(l) => {l}} + + + + ); +} + +function CodexBestiary() { + return ( + + + FORMS AND FOES + SELECT CLOSE + + + + FORMS -- BY THEIR OWN WORK + + {(id) => ( + + {PLANTS[id].name} + + + )} + + ONLY THE CATNIP FLIES AT DUSK. + ASCEND ONCE: THE SAPLING WAKES. + FELL THE UMBRELLA FOR THE APE. + + + FOES -- WITH THE HOUR + + {(id) => ( + + {FOES[id].name} + + + )} + + DUSK I, MIDNIGHT II, WITCHING III. + SPARED FOES DRIFT OFF-SCREEN. + + + + ); +} + +// --------------------------------------------------------------------------- +// Battle screen +// --------------------------------------------------------------------------- + +function BattleScreen(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + + + + + + PAUSED + START RESUME + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +export default function Nightbloom() { + const game = createNightbloom(); + onFrame((buttons) => game.frame(buttons)); + return ( + + + + + + + + + + + + + + + ); +} diff --git a/demos/nightbloom/backend.ts b/demos/nightbloom/backend.ts new file mode 100644 index 0000000..ce63784 --- /dev/null +++ b/demos/nightbloom/backend.ts @@ -0,0 +1,45 @@ +// demos/nightbloom/backend.ts — the augury: NIGHTBLOOM's "network", built on +// the effect-shell pattern (DETERMINISM.md), same shape as tidelight's wire. +// +// When a night phase begins, the engine asks the dark what is coming with +// runEffect("augury", { phase }). The DATA is a pure request -> response +// function; the TIME is the virtual clock: the omen lands as a frame-boundary +// delivery exactly one virtual second later — the same virtual second in +// every run, on every host, at every simulationHz. A forecast with dramatic +// latency, and it is still deterministic. + +import { after } from "@pocketjs/framework/clock"; +import { installEffectDriver } from "@pocketjs/framework/effects"; +import { PHASES, type PhaseId } from "./data.ts"; + +export interface AuguryRequest { + phase: PhaseId; +} + +export interface AuguryResponse { + omen: string; +} + +export function respond(kind: string, payload: unknown): unknown { + if (kind === "augury") { + const req = payload as AuguryRequest; + const phase = PHASES.find((p) => p.id === req.phase); + if (!phase) throw new Error(`nightbloom backend: unknown phase "${req.phase}"`); + return { omen: phase.omen } satisfies AuguryResponse; + } + throw new Error(`nightbloom backend: unknown effect kind "${kind}"`); +} + +/** Latency per request kind, in VIRTUAL seconds (whole-second grid — exact at + * every valid simulationHz). The dark always takes a beat to answer. */ +export function latencySeconds(kind: string): number { + return kind === "augury" ? 1.0 : 0.5; +} + +export function installAugury(): void { + installEffectDriver((cmd, deliver) => { + after(latencySeconds(cmd.kind), () => deliver(respond(cmd.kind, cmd.payload))); + }); + // The lab seam: same data, bring your own clock. + (globalThis as Record).__nightbloomAugury = { respond, latencySeconds }; +} diff --git a/demos/nightbloom/bg-dawn.png b/demos/nightbloom/bg-dawn.png new file mode 100644 index 0000000000000000000000000000000000000000..37e5584a66a193ed0e14ae11d5f3a626a79677b5 GIT binary patch literal 30754 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 LRHQ4UOoRRh*<}^% literal 0 HcmV?d00001 diff --git a/demos/nightbloom/bg-eternal.png b/demos/nightbloom/bg-eternal.png new file mode 100644 index 0000000000000000000000000000000000000000..3248c9afcaf149d69f784e0c28e8dce8ad141026 GIT binary patch literal 31059 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�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 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/boss-kasa.png b/demos/nightbloom/boss-kasa.png new file mode 100644 index 0000000000000000000000000000000000000000..cd9c6e1ba4ae1760c03cbb24053d8ab4fcf58df6 GIT binary patch literal 2141 zcmV-j2%`6iP)BFLV=lSVjA7}9QHX`iY0Sw+PVae)~s>&`2Fpp>k>zc zeM9HOb*~vA)O$2MjUaV6&lNo5Tgg=?h|KxlwR;oriO{PwzP$p-5_a4=!{MEg^WFd9 zP6Z`S^bQdhzN>q&O-~|%tRhzd1S^7s7}x8*?iYcEoI7_hB6_Ehw^o1N~e4_@)in+?KOWZfMSdOmUiH~jY{tr2y7;5u{fcsyD* zvcZ9C#1hh93+mbDs4J@xauERJ%@t^f#$rrgJ@$9w4hI@dLzT;i@K?iz#QBKZ9(Koy z!PU`S11HBwdxwORAdtfnUt8bYwIV>Co<8^CXAv>r$9bFG5%|4l8_c;S_RpouZphR* z#Ir$l^*#YojrXjK=MvjDtVtlRu7Cjaw#*iYq@SAlP7qb+5XuG?^tLU=4`iTLS0!_l3h-T;O9<)XoQN zI&IVIFAtq`P#sOL^A_AXTQtF;LA4JxI3UEX)k_^&3w&C`oIl*z#SxB-E~}Qk?$T-j zA-S$;a-q4qxdXj}i9+0EUMVv`LBut8u)Quw083sVwMK~6is^+Hf%O7bj!qu`#eoH6AxQ@O6_&)27S~y)g{;9-hP%r`foPdRh$9h+1Tqg{{&M0+lRN2(T!YD$= zxyMZt6E0H_@&u!eYj;OmH}7-3B)>#}g*Q))8{4|&VCuMSm(lzHXlHXk_5HwE5CQm8 zqB{O4Gu7B_SAQh{jYTMY-;em*2Y&PqSqksvdH#=hB3J5m5oinzAo$T3e;l{|N$*h) zoH+h#3noWivK}Lv6`4gT{ z>dpVj>Ix>nYeN<$HaPro+}5otDvAyj6~);cMTGh(W< z_$lsTcAwl1l$RLAJ=(XtP0Fk`XwoGC@;VCeGROlz_;8IyicE@!1kh{|0oZBgs}FjZ z4Ol-N))2?&pqZaf(q@!h@}V^^e&)e@9n?U6Yh@ra(@1h-nPhQY6$T2v%6x`&XgqC_+dvEOj`or28{Qi%&=RE@0(fc_brcoyVUy< zBwMt$^)Y|N3|xjFS&)pcBPFzIo9j>|S75lrl|V~rtwTX9puI%_FtoFiYvX;v>>|lE zBY>i~@B8h2mz^PA^#Tm+GbE}np6*t1v|XNQj*zW{)&`U^cMXG16eco!J`4CXMIv-| zv4Us$%0u5FzBhSA*MAq>kuvGZ_j`grU+B!cUH(6Ob_`~h`D=Mk=_x(sdM*C}^0bc_ TJ`4zX00000NkvXXu0mjf)EWn2 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/boss-uta-1.png b/demos/nightbloom/boss-uta-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c763f9d9f697f016edc913872b30b77c689ce3e7 GIT binary patch literal 1993 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 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/boss-uta-3.png b/demos/nightbloom/boss-uta-3.png new file mode 100644 index 0000000000000000000000000000000000000000..8f7274494b85ac3afd8da9ac7a22f3a918581edc GIT binary patch literal 1940 zcmV;F2W$9=P)Gf?6ucEQ>zWy zpbgrfEwK%+9?p6_hn}@{H{6o_esMlqziR!;Bmn{@H@>hk3BdHP{&p%m{pMTQu3I0= zR-7vg4>hvmPmg5xth&=IAT2S z&tCX&5%|SDQzmcUHj*v~^Pq1=$C`kD@W3VmEuTb|b3%8ly*)$zI`ff%|8wen`` zARE7HhY^s(4HWf5kqb;d_+$|ap1bjp{~Qk>QPy^T`_b(CEr$&}pRxjxki-HY0H84e zd`Lhyu3%Q<2;)0I0S-_;0nMiu2uVgjE!Rp%(uMZvPf*i6d>44RRtOwH`v>mbWs-~l zO!CT!ohFm_ZOtwn-<(~U*_M4ec{bah{we`KdOPS`%c6To#Npu-6P9_$N;0Hw}kbBIu5WXS@kqJMbdFYu^8DB&4(e07>hyOc2eq zB7i;wwlWez@Cf}q2@;;A=Owj-Sk@;^C?JKtsq30}3}~5XSu7*Kh)%s7RutD#C6V^S z2?3tTYFP`dpdI*DBqRs#Ntpk6w#ec{K+GrQy9$`<4~gx-E5Jf_2tIidbR95f@~!G~ zOp*yN9Gx<7fZ=Zl@t|2o^}81(Mxh0iSzNLP;7dQAXj=)A3tXL@wt$6@`a%Ntk?A4) z4FOn&`pFs~fH6V~D6_g`t<}1(CISEqNhDA7r<5)b5|!7rrIz`JF`#J`=p_M)?(GP$ zU0+UsiXSmRq`d`6`RWog|4s?0ymT(@wj`k5$o~1{d?D2LxaK3g@{5_tZ=33hd`kOr z0fBE72vtX9Z&K;e9Rri1mK8(UJN*6e*(L(eEGU|_K=V5Z?95%NGxBlT+qkNJ1rwG*ar;{KkHgct##)+;9fka~b8f$!|`y=kIt zPk@Mm0QLx-5;!}wCbT#YRYq9%xtG{yD{o&X5Jtr(-y zJOPcMA!Tk_x4ro2t@1XN-B=ODuO=E(z>yfSF=4snaHcZtdjg6z+i3}34U?AG0mqr0 z$L%`t0pVUs;G7Ha$#;HdsYQIjv=w*+OR2n80B}r>$$4#&()xf!zu4(caVnH2J+VGk z_je^R4^~#@iB7szRxq+;C{JFtu5AXd02ZF_u{5%!<{n5;**ivnVuvT7Rf?SStPldM zWSu`$ek`-rK__d!f|L$C?Yw{Pq0)HcVt7HWj z5kQ2-KXtAzQG_oJKi!f>Uy%^j1W1k`0E_C$>oR2o)SNX)<67GR%e{WB1{@$c>q!()[]'\"*="; + +// --------------------------------------------------------------------------- +// Playfield geometry (480x272 screen, portrait field + side panels) +// --------------------------------------------------------------------------- + +export const FIELD = { + x0: 138, + y0: 8, + w: 204, + h: 256, +} as const; + +/** Side panel boxes (left: the night; right: the roster). */ +export const PANEL_L = { x0: 0, w: 134 } as const; +export const PANEL_R = { x0: 346, w: 134 } as const; + +/** Player spawn point and movement clamp inset. */ +export const PLAYER_SPAWN = { x: FIELD.x0 + FIELD.w / 2, y: FIELD.y0 + FIELD.h - 28 }; +export const PLAYER_INSET = 10; + +/** Motes auto-collect when the player climbs above this line (the PoC). */ +export const POC_Y = FIELD.y0 + 84; + +// --------------------------------------------------------------------------- +// Ticks — the sim advances on the core's fixed 1/60 s grid, host hz agnostic +// --------------------------------------------------------------------------- + +export const TPS = 60; // ticks per virtual second (spec FIXED_DT) + +// --------------------------------------------------------------------------- +// Art manifest types (consumed by gen-assets.ts) +// --------------------------------------------------------------------------- + +export interface ArtEntry { + name: string; + prompt: string; + w: number; + h: number; + seed: number; + transparent?: boolean; + /** pixflux facing hint. Plants face east (they shoot right), foes west. */ + direction?: "north" | "south" | "east" | "west"; + /** Derive from a previous entry — evolution keeps a creature's identity. */ + initFrom?: string; + /** init_image influence 1..999 (PixelLab default 300). */ + initStrength?: number; + shading?: string; + detail?: string; +} + +const UNIT = 32; // unit sprites (pow2) +const SHOT = 32; // projectile sprites (pixflux minimum canvas; drawn at 16px) +const SCENE_W = 256; // scenes are 256x128 (pow2), drawn at 480x240 +const SCENE_H = 128; + +const PLANT_STYLE = + "adorable kawaii chibi plant creature for a pixel art garden defense game, huge sparkling eyes, " + + "tiny blushing cheeks, soft rounded shapes, pastel colors with a gentle night glow, " + + "clean thick outline, single centered character, full body"; +const FOE_STYLE = + "adorable kawaii chibi yokai spirit for a pixel art night defense game, plump rounded body, " + + "big shiny puppy eyes, tiny stubby limbs, blushing cheeks, more cute than scary, " + + "glowing pastel accents, clean silhouette, single centered character, full body, walking"; +const SHOT_STYLE = "tiny cute pixel art game projectile icon, rounded kawaii shape, soft glow, clean silhouette, centered"; + +// --------------------------------------------------------------------------- +// The roster — the five pilotable plant forms +// --------------------------------------------------------------------------- + +export type PlantId = "primrose" | "catnip" | "sakura"; + +export interface SpellDef { + name: string; + hint: string; + /** Cooldown in virtual seconds. */ + cooldown: number; +} + +export interface PlantDef { + id: PlantId; + name: string; + /** Display name of each evolution stage (index 0 = stage I). */ + stageNames: [string, string, string]; + hp: [number, number, number]; + /** Flat damage shaved off every hit taken (the defense stat). */ + armor: [number, number, number]; + /** Unfocused movement speed in px/s (focus halves it). */ + speed: number; + /** Which way the committed ART faces at rest: 1 = right, -1 = left. The + * renderer mirrors with facing * artFacing so every form looks where it + * flies regardless of how its portrait came out of the generator. */ + artFacing: 1 | -1; + /** Shot damage per bullet and seconds between volleys, per stage. */ + dmg: [number, number, number]; + period: [number, number, number]; + /** Streams per volley, per stage (homing orbs / bolts / petal fan width). */ + streams: [number, number, number]; + /** Sprite file per stage — full literals so the pak build collects them. */ + sprites: [string, string, string]; + /** Glow thresholds to reach stage II and stage III. */ + evolveAt: [number, number]; + /** How this form earns glow — the evolution law, shown in the codex. */ + law: string; + spell: SpellDef; +} + +export const PLANT_ORDER: PlantId[] = ["catnip", "sakura", "primrose"]; + +export const PLANTS: Record = { + catnip: { + id: "catnip", + name: "CATNIP KIT", + stageNames: ["CATNIP KIT", "NEKOMATA", "NINELIVES"], + hp: [110, 140, 170], + armor: [0, 0, 0], + speed: 120, + artFacing: 1, + dmg: [7, 8, 10], + period: [0.34, 0.3, 0.26], + streams: [1, 2, 2], + sprites: ["p-catnip-1.png", "p-catnip-2.png", "p-catnip-3.png"], + evolveAt: [420, 1400], + law: "HOMING ORBS, AND IT DANCES WITH DEATH: WIDER GRAZE, DOUBLE GLOW", + spell: { name: "NINE LIVES", hint: "9 HOMING ORBS + CLEAR NEAR", cooldown: 18 }, + }, + sakura: { + id: "sakura", + name: "SAKURA SENTINEL", + stageNames: ["SAPLING", "GUARDIAN", "PETALSTORM"], + hp: [110, 140, 170], + armor: [0, 1, 2], + speed: 105, + artFacing: -1, + dmg: [3, 4, 5], + period: [0.34, 0.3, 0.26], + streams: [3, 5, 7], + sprites: ["p-sakura-1.png", "p-sakura-2.png", "p-sakura-3.png"], + evolveAt: [420, 1400], + law: "SOFT PETALS, TRUE DAMAGE -- AND EVERY HIT HEALS THE MOST WOUNDED", + spell: { name: "PETALFALL", hint: "CLEAR EVERY SHOT, SLOW ALL", cooldown: 15 }, + }, + primrose: { + id: "primrose", + name: "MOON PRIMROSE", + stageNames: ["SPROUT", "FULL BLOOM", "MOONRISEN"], + hp: [100, 125, 150], + armor: [0, 0, 0], + speed: 110, + artFacing: -1, + dmg: [14, 18, 22], + period: [0.42, 0.38, 0.34], + streams: [1, 1, 1], + sprites: ["p-primrose-1.png", "p-primrose-2.png", "p-primrose-3.png"], + evolveAt: [360, 1200], + law: "BANANA BOOMERANGS: THREE ALOFT, CATCH THEM COMING BACK. MOTES x2", + spell: { name: "MOONRISE", hint: "+100 GLOW TO THE WHOLE ROSTER", cooldown: 15 }, + }, +}; + +/** Glow a collected mote grants the piloted plant (primrose doubles it). */ +export const MOTE_GLOW = 8; +/** Glow per graze (a bullet brushing the hitbox without touching). */ +export const GRAZE_GLOW = 2; +/** Graze radius around the hitbox, px. */ +export const GRAZE_R = 11; +/** The catnip dances with death: wider graze ring, double its glow. */ +export const CATNIP_GRAZE_R = 16; +export const CATNIP_GRAZE_MULT = 2; +/** Sakura's kindness: every damaging petal heals the most wounded waking + * form this much. */ +export const SAKURA_HEAL = 1; +/** The gorilla's boomerangs: at most `max` aloft; thrown up at `throwVy` + * px/s (per stage), decelerating `decel` px/s^2 until they turn, then + * homing back at `back` px/s. Caught within `catchR` px. A banana never + * despawns on a hit — it cuts through and hits again on the way home, + * one touch per `hitCd` ticks. */ +export const BANANA = { + max: 3, + throwVy: [240, 265, 290], + decel: 260, + back: 240, + catchR: 14, + hitCd: 18, +} as const; +/** Player hitbox radius, px (danmaku-small; SQUARE focus reveals it). */ +export const HIT_R = 3; +/** Seconds of mercy invulnerability after taking a hit. */ +export const HURT_INVULN = 1.8; +/** When the piloted form dies, you have this long to switch — or the night + * ends. The last breath is a choice. */ +export const WILT_WINDOW = 1.5; +/** Seconds between form switches. */ +export const SWITCH_COOLDOWN = 0.5; +/** Focus movement multiplier while SQUARE is held. */ +export const FOCUS_RATE = 0.5; + +// --------------------------------------------------------------------------- +// Foes +// --------------------------------------------------------------------------- + +export type FoeId = "wisp" | "kasa" | "usagi" | "uta"; + +export interface FoeDef { + id: FoeId; + name: string; + stageNames: [string, string, string]; + hp: [number, number, number]; + /** Flat reduction per non-true hit (armor). */ + armor: [number, number, number]; + /** Motes dropped on death. */ + bounty: [number, number, number]; + /** Descent/weave speed in px/s. */ + speed: [number, number, number]; + /** Seconds between volleys. */ + firePeriod: [number, number, number]; + /** Enemy bullet speed in px/s. */ + shotSpeed: [number, number, number]; + sprites: [string, string, string]; + law: string; +} + +export const FOE_ORDER: FoeId[] = ["wisp", "kasa", "usagi", "uta"]; + +export const FOES: Record = { + wisp: { + id: "wisp", + name: "LANTERN WISP", + stageNames: ["WISP", "TWINFLAME", "PYRE WISP"], + hp: [45, 70, 105], + armor: [0, 0, 0], + bounty: [2, 3, 4], + speed: [26, 30, 34], + firePeriod: [1.9, 1.6, 1.4], + shotSpeed: [76, 86, 96], + sprites: ["f-wisp-1.png", "f-wisp-2.png", "f-wisp-3.png"], + law: "DRIFTS DOWN AND AIMS AT YOU", + }, + kasa: { + id: "kasa", + name: "KASA RONIN", + stageNames: ["KASA", "IRON KASA", "WARLORD"], + hp: [150, 220, 300], + armor: [4, 6, 8], + bounty: [4, 5, 6], + speed: [11, 12, 13], + firePeriod: [2.2, 2.0, 1.8], + shotSpeed: [66, 74, 82], + sprites: ["f-kasa-1.png", "f-kasa-2.png", "f-kasa-3.png"], + law: "ARMORED SPREADS. PETALS PIERCE", + }, + usagi: { + id: "usagi", + name: "MOON RABBIT", + stageNames: ["RABBIT", "POUNDER", "VANGUARD"], + hp: [36, 55, 80], + armor: [0, 0, 0], + bounty: [2, 3, 4], + speed: [55, 62, 70], + firePeriod: [1.4, 1.2, 1.0], + shotSpeed: [110, 122, 134], + sprites: ["f-usagi-1.png", "f-usagi-2.png", "f-usagi-3.png"], + law: "WEAVES AND SNIPES, FAST, FRAIL", + }, + uta: { + id: "uta", + name: "NIGHT SPARROW", + stageNames: ["SPARROW", "CHANTER", "DIVA"], + hp: [80, 120, 170], + armor: [0, 1, 2], + bounty: [5, 6, 8], + speed: [22, 24, 26], + firePeriod: [2.6, 2.3, 2.0], + shotSpeed: [58, 64, 70], + sprites: ["f-uta-1.png", "f-uta-2.png", "f-uta-3.png"], + law: "RINGS; HER SONG HASTENS THE REST", + }, +}; + +/** uta's song: other foes fire this much faster while any uta lives. */ +export const UTA_HASTE = 0.75; // fire-period multiplier + +// --------------------------------------------------------------------------- +// The night — phases, waves, bosses +// --------------------------------------------------------------------------- + +export type PhaseId = "dusk" | "midnight" | "witching"; + +export interface PhaseDef { + id: PhaseId; + name: string; + /** Virtual second the phase begins. */ + at: number; + /** Foes spawned during this phase arrive at this evolution stage. */ + foeStage: 1 | 2 | 3; + omen: string; +} + +export const PHASES: PhaseDef[] = [ + { id: "dusk", name: "DUSK", at: 0, foeStage: 1, omen: "LANTERNS DRIFT DOWN FROM THE BAMBOO" }, + { id: "midnight", name: "MIDNIGHT", at: 56, foeStage: 2, omen: "THE HORDE DEEPENS WITH THE NIGHT" }, + { id: "witching", name: "WITCHING HOUR", at: 104, foeStage: 3, omen: "THE DIVA TAKES THE STAGE" }, +]; + +export interface WaveDef { + /** Virtual second the wave crosses the treeline. */ + at: number; + /** Foe kinds; entry x-slots are drawn from the seeded night RNG. */ + spawn: FoeId[]; +} + +export const WAVES: WaveDef[] = [ + { at: 4, spawn: ["wisp", "wisp", "wisp"] }, + { at: 14, spawn: ["wisp", "wisp", "usagi"] }, + { at: 24, spawn: ["kasa", "wisp", "wisp"] }, + { at: 34, spawn: ["usagi", "usagi", "wisp", "wisp"] }, + { at: 44, spawn: ["uta", "wisp", "wisp", "wisp"] }, + { at: 56, spawn: ["wisp", "wisp", "wisp", "usagi", "usagi"] }, + { at: 66, spawn: ["kasa", "kasa", "uta"] }, + { at: 96, spawn: ["usagi", "usagi", "usagi", "wisp", "wisp"] }, + { at: 104, spawn: ["uta", "uta", "kasa"] }, +]; + +/** The midboss crosses at this second (an IRON KASA grown monstrous). */ +export const MIDBOSS_AT = 78; +/** The final boss takes the stage at this second (the NIGHT SPARROW DIVA). */ +export const BOSS_AT = 116; + +export interface BossPhaseDef { + /** Spell card name, announced on the banner. */ + card: string; + hp: number; + /** Seconds before the card times out and the phase advances anyway. */ + timeout: number; + /** This phase's transformation art (64x64) and its draw size in px — + * every card change is a visible metamorphosis. */ + sprite: string; + size: number; +} + +export interface BossDef { + name: string; + /** The boss's own cry — played on entry and on every transformation. */ + voice: SfxKind; + phases: BossPhaseDef[]; +} + +export const MIDBOSS: BossDef = { + name: "IRON KASA, GROWN WRONG", + voice: "boss-umbrella", + phases: [{ card: "UMBRELLA SIGN -- RIBS OF THE STORM", hp: 620, timeout: 30, sprite: "boss-kasa.png", size: 56 }], +}; + +export const BOSS: BossDef = { + name: "THE NIGHT SPARROW DIVA", + voice: "boss-bird", + phases: [ + { card: "NIGHT SONG -- WANDERING CHORUS", hp: 720, timeout: 36, sprite: "boss-uta-1.png", size: 52 }, + { card: "MOCHI SIGN -- MOONFALL CANTATA", hp: 830, timeout: 36, sprite: "boss-uta-2.png", size: 58 }, + { card: "FINALE -- THE ETERNAL NIGHT", hp: 950, timeout: 44, sprite: "boss-uta-3.png", size: 66 }, + ], +}; + +/** Motes the bosses shed per phase broken. */ +export const BOSS_PHASE_BOUNTY = 14; + +export const START_GLOW = 0; + +/** The night RNG seed — one night, one seed, one tape. */ +export const NIGHT_SEED = 0x9e3779b9; + +// --------------------------------------------------------------------------- +// Sound events — the engine EMITS these; a host-side sink (sfx.ts) renders +// them when the host has an audio device. Pure output: no sink, no sound, +// same simulation either way. +// --------------------------------------------------------------------------- + +export type SfxKind = + | "shoot" + | "unlock" + | "heal" + | "boss-bird" + | "boss-umbrella" + | "hit" + | "kill" + | "hurt" + | "wilt" + | "graze" + | "mote" + | "switch" + | "spell" + | "evolve" + | "bossbreak" + | "dawn" + | "eternal"; + +// --------------------------------------------------------------------------- +// Projectiles + scenes +// --------------------------------------------------------------------------- + +/** Player shot art (enemy danmaku is drawn as native dots). */ +export const SHOTS = { + orb: { sprite: "shot-orb.png" }, + mochi: { sprite: "shot-mochi.png" }, + banana: { sprite: "shot-banana.png" }, +} as const; + +/** The piloted avatar grows with its stage, pokemon-style (px). The HITBOX + * does not — danmaku manners: what you dodge with is always HIT_R. */ +export const AVATAR_SIZE = [22, 27, 32] as const; + +export const SCENES = { + title: "bg-title.png", + dawn: "bg-dawn.png", + eternal: "bg-eternal.png", +} as const; + +export const MOTE_SPRITE = "mote.png"; + +// --------------------------------------------------------------------------- +// PixelLab manifest — every committed asset, seeded. Order matters: initFrom +// chains (stage II from I, III from II) require the base to exist first. +// --------------------------------------------------------------------------- + +function plantArt(def: PlantDef, stagePrompts: [string, string, string], baseSeed: number): ArtEntry[] { + return stagePrompts.map((p, i) => ({ + name: def.sprites[i], + prompt: `${p}, ${PLANT_STYLE}`, + w: UNIT, + h: UNIT, + seed: baseSeed + i, + transparent: true, + direction: "east" as const, + ...(i > 0 ? { initFrom: def.sprites[i - 1], initStrength: 320 } : {}), + })); +} + +function foeArt(def: FoeDef, stagePrompts: [string, string, string], baseSeed: number): ArtEntry[] { + return stagePrompts.map((p, i) => ({ + name: def.sprites[i], + prompt: `${p}, ${FOE_STYLE}`, + w: UNIT, + h: UNIT, + seed: baseSeed + i, + transparent: true, + direction: "west" as const, + ...(i > 0 ? { initFrom: def.sprites[i - 1], initStrength: 320 } : {}), + })); +} + +export const ART: ArtEntry[] = [ + // --- plants (32x32, transparent, face east) ----------------------------- + // The moon primrose is a gap-moe gorilla: a mountain of muscle with the + // sweetest little face. Custom style — the soft plant suffix would melt + // the abs. + { + name: "p-primrose-1.png", + prompt: + "small young gorilla plant guardian cub, a muscular little body with baby six-pack abs, " + + "tiny ultra-cute kawaii face with big sparkling eyes and blushing cheeks, " + + "a small silver moonflower sprout on its head, pixel art game sprite, " + + "clean thick outline, single centered character, full body", + w: UNIT, h: UNIT, seed: 1010, transparent: true, direction: "east", + }, + { + name: "p-primrose-2.png", + prompt: + "the same gorilla grown bigger and mightier, broad shoulders, prominent carved six-pack abs " + + "on its bare belly, flexing both arms, silver moonflower in full bloom on its head, the same tiny " + + "adorable sparkling-eyed blushing face, pixel art game sprite, clean thick outline, full body", + w: UNIT, h: UNIT, seed: 1011, transparent: true, direction: "east", + initFrom: "p-primrose-1.png", initStrength: 320, + }, + { + name: "p-primrose-3.png", + prompt: + "the same gorilla fully grown huge, a bodybuilder mountain of muscle with a prominent " + + "six-pack on its bare belly, a glowing white crescent halo behind its shoulders, radiant " + + "moonflower crown, the same tiny sweet blushing face, pixel art game sprite, clean thick outline, full body", + w: UNIT, h: UNIT, seed: 1012, transparent: true, direction: "east", + initFrom: "p-primrose-2.png", initStrength: 320, + }, + ...plantArt(PLANTS.catnip, [ + "tiny baby kitten sprout, small and round, sleek black fur with golden paws chest and ear tips, a white crescent moon mark on its forehead, leaf ears, huge adorable eyes, curled leaf tail", + "the same black and gold cat grown into a young sleek two-tailed cat blossom, taller now, white crescent moon mark glowing on its forehead, golden bell collar, playful grin, two swishing leaf tails", + "the same black and gold cat fully grown, a large regal spirit cat, many glowing petal tails fanned wide, a bright white full moon mark shining on its forehead, tiny golden crown, sparkling whiskers", + ], 1030), + ...plantArt(PLANTS.sakura, [ + "tiny baby cherry blossom sapling, small and round with a shy blushing face and big soft eyes, a few pink petals drifting", + "the same cherry tree grown taller into a young blossom guardian, swirl of pink petals, calm smile", + "the same tree fully grown, a great wide sakura spirit in storm bloom, petal vortex, ancient serene face", + ], 1050), + // --- foes (32x32, transparent, walk west) -------------------------------- + ...foeArt(FOES.wisp, [ + "floating paper lantern ghost with a tiny warm flame heart, chubby cheeks and a happy grin, soft ragged paper skirt", + "the same lantern ghost with twin cozy flames and a bigger happy grin, gently scorched paper edges", + "the same lantern ghost with marshmallow-soft blue-white pyre flames, wide sparkly eyes", + ], 2010), + ...foeArt(FOES.kasa, [ + "chubby one-eyed umbrella yokai with a happy waggling tongue, tiny straw sandals, little toy wooden blade", + "the same umbrella yokai grown round in cute iron-ribbed armor with a little war fan, one big glowing eye", + "the same umbrella yokai as a tiny round warlord in shiny lacquered armor, big sparkly crimson eye, two toy blades", + ], 2020), + ...foeArt(FOES.usagi, [ + "extra fluffy little white moon rabbit spirit hopping with a tiny mochi mallet, big round pink eyes, chubby cheeks", + "the same fluffy moon rabbit as a little drummer-warrior, red headband, bigger mochi hammer, determined puffy face", + "the same fluffy moon rabbit as a royal vanguard, tiny crescent banner on the back, great mallet, softly glowing fur", + ], 2030), + ...foeArt(FOES.uta, [ + "round little night sparrow songstress spirit holding a tiny lantern staff, beak open in a happy song, fluffy feathers", + "the same fluffy night sparrow with a cozy feathered cloak, bright music notes swirling around", + "the same fluffy night sparrow as a radiant little diva, soft plume crown, spiral of glowing song light, sweet face", + ], 2040), + // --- projectiles + mote (32x32, transparent, drawn at 16px) --------------- + { name: "shot-orb.png", prompt: `round pink energy orb with a tiny paw print, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3002, transparent: true }, + { name: "shot-mochi.png", prompt: `small round white mochi rice cake, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3003, transparent: true }, + { name: "mote.png", prompt: `small silver-blue moonlight droplet, sparkling, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3004, transparent: true }, + { name: "shot-banana.png", prompt: `curved ripe yellow banana, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3005, transparent: true }, + // --- boss transformation portraits (64x64, chained from the mob art) ------ + { + name: "boss-kasa.png", + prompt: + "huge one-eyed umbrella yokai warlord grown monstrous, towering shiny lacquered armor, " + + "storm ribs spread wide like a broken umbrella crown, one enormous glowing crimson eye, " + + "two toy blades, adorable chubby menace, deep purple lacquer and crimson trim, " + FOE_STYLE, + w: 64, h: 64, seed: 2050, transparent: true, direction: "south", + }, + { + name: "boss-uta-1.png", + prompt: + "the night sparrow diva on her stage: a fluffy round songstress bird in a sparkling dress, " + + "soft plume crown, lantern staff raised, music notes swirling, warm brown feathers " + + "with rose-pink chest, " + FOE_STYLE, + w: 64, h: 64, seed: 2051, transparent: true, direction: "south", + }, + { + name: "boss-uta-2.png", + prompt: + "the same diva transformed mid-song: wings spread wide, radiant feather cloak flaring, " + + "twin glowing song spirals around her, brighter plume crown, " + FOE_STYLE, + w: 64, h: 64, seed: 2052, transparent: true, direction: "south", + initFrom: "boss-uta-1.png", initStrength: 300, + }, + { + name: "boss-uta-3.png", + prompt: + "the same diva's final form: an ascended phoenix-like night sparrow, blazing moonlit plumage, " + + "a glowing crescent halo crown, the eternal night swirling around her wings, " + FOE_STYLE, + w: 64, h: 64, seed: 2053, transparent: true, direction: "south", + initFrom: "boss-uta-2.png", initStrength: 300, + }, + // --- scenes (256x128 opaque, drawn at 480x240) ---------------------------- + { + name: "bg-title.png", + prompt: + "stone shrine in a night garden under an enormous full moon, sakura tree and bamboo grove, " + + "fireflies, deep indigo sky, cozy dreamy soft pastel night, gentle glow, detailed pixel art", + w: SCENE_W, h: SCENE_H, seed: 4001, shading: "detailed shading", detail: "highly detailed", + }, + { + name: "bg-dawn.png", + prompt: + "the same stone shrine garden at the first golden dawn, sun rising over the bamboo grove, " + + "soft warm pastel light washing the grass, gentle and hopeful, detailed pixel art", + w: SCENE_W, h: SCENE_H, seed: 4003, shading: "detailed shading", detail: "highly detailed", + initFrom: "bg-title.png", initStrength: 300, + }, + { + name: "bg-eternal.png", + prompt: + "the same stone shrine garden under a huge ominous crimson moon, black bamboo silhouettes, " + + "soft rose mist over the grass, storybook-spooky plum and rose palette, detailed pixel art", + w: SCENE_W, h: SCENE_H, seed: 4004, shading: "detailed shading", detail: "highly detailed", + initFrom: "bg-title.png", initStrength: 300, + }, +]; + +// --------------------------------------------------------------------------- +// Content contract — engine.validateContent() asserts this in the sim test +// --------------------------------------------------------------------------- + +export function validateContent(): string[] { + const problems: string[] = []; + const artNames = new Set(); + const pow2 = (n: number) => n >= 16 && n <= 512 && (n & (n - 1)) === 0; + + for (const a of ART) { + if (artNames.has(a.name)) problems.push(`duplicate art entry "${a.name}"`); + artNames.add(a.name); + if (!pow2(a.w) || !pow2(a.h)) problems.push(`art "${a.name}" is ${a.w}x${a.h}, not pow2 16..512`); + if (a.initFrom && !artNames.has(a.initFrom)) { + problems.push(`art "${a.name}" initFrom "${a.initFrom}" is not defined earlier in the manifest`); + } + } + + for (const id of PLANT_ORDER) { + const p = PLANTS[id]; + if (p.id !== id) problems.push(`plant "${id}" has mismatched id "${p.id}"`); + if (!(p.evolveAt[0] < p.evolveAt[1])) problems.push(`plant "${id}" evolve thresholds not ascending`); + if (p.speed <= 0) problems.push(`plant "${id}" has non-positive speed`); + for (const s of p.sprites) if (!artNames.has(s)) problems.push(`plant "${id}" sprite "${s}" missing from ART`); + for (let i = 0; i < 3; i++) { + if (p.hp[i] <= 0 || p.dmg[i] <= 0 || p.period[i] <= 0 || p.streams[i] <= 0) { + problems.push(`plant "${id}" stage ${i + 1} has a non-positive stat`); + } + } + } + + for (const id of FOE_ORDER) { + const f = FOES[id]; + if (f.id !== id) problems.push(`foe "${id}" has mismatched id "${f.id}"`); + for (const s of f.sprites) if (!artNames.has(s)) problems.push(`foe "${id}" sprite "${s}" missing from ART`); + for (let i = 0; i < 3; i++) { + if (f.hp[i] <= 0 || f.speed[i] <= 0 || f.firePeriod[i] <= 0 || f.shotSpeed[i] <= 0) { + problems.push(`foe "${id}" stage ${i + 1} has a non-positive stat`); + } + } + } + + let prev = -1; + for (const w of WAVES) { + if (w.at <= prev) problems.push(`wave at ${w.at}s is not strictly after ${prev}s`); + prev = w.at; + if (w.at >= BOSS_AT) problems.push(`wave at ${w.at}s spawns after the boss (${BOSS_AT}s)`); + for (const foe of w.spawn) if (!FOES[foe]) problems.push(`wave at ${w.at}s spawns unknown foe "${foe}"`); + } + + for (let i = 1; i < PHASES.length; i++) { + if (PHASES[i].at <= PHASES[i - 1].at) problems.push(`phase "${PHASES[i].id}" does not start after "${PHASES[i - 1].id}"`); + } + if (!(MIDBOSS_AT < BOSS_AT)) problems.push("midboss must arrive before the boss"); + for (const b of [MIDBOSS, BOSS]) { + if (b.phases.length === 0) problems.push(`boss "${b.name}" has no spell cards`); + for (const ph of b.phases) { + if (!artNames.has(ph.sprite)) problems.push(`boss card "${ph.card}" sprite "${ph.sprite}" missing from ART`); + if (ph.hp <= 0 || ph.timeout <= 0 || ph.size <= 0) problems.push(`boss card "${ph.card}" has a non-positive stat`); + } + } + + for (const key of Object.keys(SCENES) as (keyof typeof SCENES)[]) { + if (!artNames.has(SCENES[key])) problems.push(`scene "${key}" sprite "${SCENES[key]}" missing from ART`); + } + if (!artNames.has(MOTE_SPRITE)) problems.push(`mote sprite missing from ART`); + + return problems; +} diff --git a/demos/nightbloom/engine.ts b/demos/nightbloom/engine.ts new file mode 100644 index 0000000..c16bbb6 --- /dev/null +++ b/demos/nightbloom/engine.ts @@ -0,0 +1,1287 @@ +// demos/nightbloom/engine.ts — the night engine: a vertical danmaku battle +// folded over the virtual clock. No JSX here; app.tsx is a thin renderer +// over these signals, which keeps the whole game host-agnostic and +// sim-testable — the tidelight architecture, applied to a bullet-hell. +// +// The player pilots one plant form at the bottom of the field, switches +// forms mid-fight (CIRCLE / L / R), holds CROSS to fire and SQUARE to focus, +// and answers the descending horde's patterns with per-form spell cards. +// +// Determinism contract (DETERMINISM.md): +// - the world advances in MICRO-TICKS on the core's fixed 1/60 s grid: a +// battle frame runs its FULL ticksPerFrame() batch (or none, for pause), +// with the batch aligned so the tick count at virtual time t is exactly +// (t - tStart) * 60 at every simulationHz — the 2 Hz world is a strict +// subsample of the 60 Hz world, gameplay included; +// - edge-detected input is applied on the FIRST tick of its host frame's +// batch: a press at second P lands on battle tick (P - tStart) * 60 + 1 +// at every valid hz, which keeps input-driven runs subsample-exact; +// - held input (movement, fire, focus) reads the raw held mask per tick: +// a HOLD's level track goes true at the same battle tick at every rate, +// so hold-driven tapes subsample exactly (one-frame pulses of held verbs +// are not rate-portable; tapes steer with holds, as tidelight's cadence +// rules already demand); +// - pattern math uses a QUANTIZED sine table (1/8192 steps), because raw +// Math.sin is not bit-specified across JS engines and a danmaku spiral +// must replay byte-exactly on every host; +// - randomness is one seeded xorshift32 stream drawn only inside ticks — +// spawn slots are as replayable as everything else; +// - float fx drift by battle-tick age; toasts expire through after() on +// the virtual clock, epoch-guarded; the phase omen arrives through the +// effect shell (backend.ts); +// - sound is an OUTPUT: the engine pokes a host sound sink (sfx.ts) and +// reads nothing back — hosts without WebAudio never install one, and +// the simulation is byte-identical either way. + +import { createSignal, type Accessor } from "solid-js"; +import { after, ticksPerFrame } from "@pocketjs/framework/clock"; +import { runEffect } from "@pocketjs/framework/effects"; +import { BTN } from "@pocketjs/framework/input"; +import { + BANANA, + BOSS, + BOSS_AT, + BOSS_PHASE_BOUNTY, + CATNIP_GRAZE_MULT, + CATNIP_GRAZE_R, + FIELD, + FOCUS_RATE, + FOES, + GRAZE_GLOW, + GRAZE_R, + HIT_R, + HURT_INVULN, + MIDBOSS, + MIDBOSS_AT, + MOTE_GLOW, + NIGHT_SEED, + PHASES, + PLANT_ORDER, + PLANTS, + PLAYER_INSET, + PLAYER_SPAWN, + POC_Y, + SAKURA_HEAL, + SWITCH_COOLDOWN, + TPS, + UTA_HASTE, + WAVES, + WILT_WINDOW, + type BossDef, + type FoeId, + type PhaseId, + type PlantId, + type SfxKind, +} from "./data.ts"; + +export type Outcome = "title" | "battle" | "dawn" | "eternal"; + +/** Poke the host's sound sink, if any (sfx.ts installs one where WebAudio + * exists). Pure OUTPUT: reads nothing back, so the sim is byte-identical + * with or without audio. */ +function sfx(kind: SfxKind): void { + const sink = (globalThis as Record).__nightbloomSfx as ((k: SfxKind) => void) | undefined; + if (sink) sink(kind); +} + +// --------------------------------------------------------------------------- +// Quantized trig — bit-identical on every JS engine +// --------------------------------------------------------------------------- + +/** 64-step sine table quantized to 1/8192: far coarser than any engine's + * last-ulp sin() divergence, so the values are cross-engine constants. */ +const SIN: number[] = Array.from({ length: 64 }, (_, i) => Math.round(Math.sin((i / 64) * Math.PI * 2) * 8192) / 8192); +const sinA = (a: number): number => SIN[((a % 64) + 64) % 64]; +const cosA = (a: number): number => SIN[(((a + 16) % 64) + 64) % 64]; +/** Angle index pointing straight down (+y). 0 = +x, quarter turn = 16. */ +const A_DOWN = 16; + +// --------------------------------------------------------------------------- +// Reactive cells — a signal dressed as a readable-callable with .set() +// --------------------------------------------------------------------------- + +export interface Cell { + (): T; + set: (v: T) => void; +} + +function cell(v: T): Cell { + const [get, set] = createSignal(v); + const f = (() => get()) as Cell; + f.set = (nv: T) => void set(() => nv); + return f; +} + +// --------------------------------------------------------------------------- +// Entities +// --------------------------------------------------------------------------- + +export interface PlantState { + kind: PlantId; + stage: Cell; + hp: Cell; + glow: Cell; + /** 0..1 — spell readiness for the HUD arc. */ + spellReady: Cell; + spellCdTicks: number; + /** Locked forms show as ? on the roster until the night wakes them. */ + unlocked: Cell; + /** Battle tick the form woke on (-1 = from the start) — drives the + * roster card's rainbow reveal sweep. */ + unlockedAt: Cell; +} + +export interface FoeInst { + id: number; + kind: FoeId; + stage: number; + x: Cell; + y: Cell; + hp: Cell; + /** wisp/uta hover altitude; usagi weave direction lives in vx. */ + hoverY: number; + vx: number; + fireCd: number; + slowUntil: number; + /** Hover ticks left before a wisp/uta drifts on down. */ + station: number; +} + +export interface BossInst { + def: BossDef; + /** True for the midboss (waves resume after it breaks). */ + mid: boolean; + phase: Cell; + hp: Cell; + x: Cell; + y: Cell; + timeoutTicks: number; + fireCd: number; + fireCd2: number; + /** Spiral angle cursor (table index units). */ + spiral: number; + born: number; +} + +export type EnemyShotKind = "pink" | "cyan" | "amber" | "mochi"; + +export interface EnemyShot { + id: number; + kind: EnemyShotKind; + x: Cell; + y: Cell; + vx: number; + vy: number; + dmg: number; + grazed: boolean; +} + +export type PlayerShotKind = "orb" | "petal" | "banana"; + +export interface PlayerShot { + id: number; + kind: PlayerShotKind; + x: Cell; + y: Cell; + vx: number; + vy: number; + dmg: number; + /** True damage ignores armor (petals, spells). */ + pierce: boolean; + /** How many more bodies a bolt may pass through. */ + through: number; + homing: boolean; + /** Banana boomerang: true once it has turned and is flying home. */ + ret: boolean; + /** Banana boomerang: ticks until it may damage again (it never despawns + * on a hit — it cuts through). */ + hitCd: number; + /** Roster index that fired it — glow is credited to the worker. */ + owner: number; +} + +export interface MoteInst { + id: number; + x: Cell; + y: Cell; +} + +export interface FloatFx { + id: number; + x: number; + y: number; + text: string; + tone: "lumen" | "hurt" | "ward" | "evolve"; + born: number; +} + +/** Float fx live this many ticks (0.9 s), pruned by the tick itself. */ +export const FX_LIFE = 54; + +export interface Toast { + id: number; + text: string; +} + +export interface Nightbloom { + outcome: Accessor; + paused: Accessor; + /** 0 = closed, 1 = the pilot's manual, 2 = forms & foes. */ + codexPage: Accessor; + phase: Accessor; + augury: Accessor; + second: Accessor; + waveIdx: Accessor; + score: Accessor; + graze: Accessor; + kills: Accessor; + bestStage: Accessor; + px: Accessor; + py: Accessor; + focus: Accessor; + invuln: Accessor; + activeIdx: Accessor; + roster: PlantState[]; + active: () => PlantState; + /** -1 | 0 | 1 — the strafe direction, for the avatar's lean. */ + lastDx: Accessor; + /** 1 faces right (the art's rest pose), -1 mirrors left. */ + facing: Accessor; + /** The pilot is dead and the last-breath switch window is open. */ + wilting: Accessor; + wiltSeconds: Accessor; + foes: Accessor; + boss: Accessor; + bossCard: Accessor; + bossCardSeconds: Accessor; + /** Battle tick of the last boss entry/metamorphosis, for the flash ring. */ + bossFlash: Accessor; + enemyShots: Accessor; + playerShots: Accessor; + motes: Accessor; + fxs: Accessor; + /** The battle tick, for age-deriving float fx drift and star parallax. */ + fxTick: Accessor; + toasts: Accessor; + frame: (buttons: number) => void; + start: () => void; + toTitle: () => void; +} + +// Enemy contact/bullet damage by foe stage (bosses use the stage-3 value). +const BULLET_DMG = [9, 11, 13]; +const RAM_DMG = 20; +/** Bullet cap — spawns beyond this are skipped, deterministically. */ +const MAX_ENEMY_SHOTS = 72; +/** Hovering foes (wisp, uta) stay on station this long, then drift on. */ +const STATION_TICKS = 8 * TPS; +/** The world scrolls on beneath everyone: even a hovering foe sinks with it, + * so an unkilled monster always leaves the field eventually. */ +const WORLD_DRIFT = 10 / TPS; +const MAX_PLAYER_SHOTS = 40; +const MAX_MOTES = 24; + +const SWITCH_TICKS = Math.round(SWITCH_COOLDOWN * TPS); +const HURT_TICKS = Math.round(HURT_INVULN * TPS); + +export function createNightbloom(): Nightbloom { + const outcome = cell("title"); + const paused = cell(false); + const codexPage = cell(0); + const phase = cell("dusk"); + const augury = cell(""); + const second = cell(0); + const waveIdx = cell(0); + const score = cell(0); + const graze = cell(0); + const kills = cell(0); + const bestStage = cell(1); + const px = cell(PLAYER_SPAWN.x); + const py = cell(PLAYER_SPAWN.y); + const focus = cell(false); + const invulnOn = cell(false); + const activeIdx = cell(0); + const foes = cell([]); + const boss = cell(null); + const bossCard = cell(""); + const bossCardSeconds = cell(0); + const enemyShots = cell([]); + const playerShots = cell([]); + const motes = cell([]); + const fxs = cell([]); + const toasts = cell([]); + const fxTick = cell(0); + const bossFlash = cell(-1); + + const roster: PlantState[] = PLANT_ORDER.map((kind, i) => ({ + kind, + stage: cell(1), + hp: cell(PLANTS[kind].hp[0]), + glow: cell(0), + spellReady: cell(1), + spellCdTicks: 0, + unlocked: cell(i === 0), // only the catnip answers at dusk + unlockedAt: cell(-1), + })); + + let tick = 0; + let prevButtons = 0; + let rng = NIGHT_SEED >>> 0; + let idSeq = 0; + let epoch = 0; + let nextWave = 0; + let nextPhase = 0; + let fireCd = 0; + let switchCd = 0; + let invulnTicks = 0; + let midbossDone = false; + let bossDone = false; + let wiltTicks = 0; + let rescues = 0; + const wilting = cell(false); + const wiltSeconds = cell(0); + const lastDx = cell(0); + const facing = cell(1); + + // -- deterministic helpers ------------------------------------------------ + + function rnd(n: number): number { + rng ^= (rng << 13) >>> 0; + rng = rng >>> 0; + rng ^= rng >>> 17; + rng ^= (rng << 5) >>> 0; + rng = rng >>> 0; + return rng % n; + } + + function toast(text: string): void { + const t: Toast = { id: ++idSeq, text }; + toasts.set([...toasts(), t]); + const at = epoch; + after(2.5, () => { + if (at === epoch) toasts.set(toasts().filter((x) => x.id !== t.id)); + }); + } + + function fx(x: number, y: number, text: string, tone: FloatFx["tone"]): void { + fxs.set([...fxs(), { id: ++idSeq, x, y, text, tone, born: tick }]); + } + + const active = (): PlantState => roster[activeIdx()]; + const alive = (p: PlantState): boolean => p.hp() > 0; + const ready = (p: PlantState): boolean => alive(p) && p.unlocked(); + + function unlock(kind: PlantId, line: string): void { + const p = roster.find((r) => r.kind === kind); + if (!p || p.unlocked()) return; + p.unlocked.set(true); + p.unlockedAt.set(tick); + toast(line); + sfx("unlock"); + } + const plantMaxHp = (p: PlantState): number => PLANTS[p.kind].hp[p.stage() - 1]; + + // -- state churn ----------------------------------------------------------- + + function reset(): void { + epoch++; + tick = 0; + rng = NIGHT_SEED >>> 0; + nextWave = 0; + nextPhase = 0; + fireCd = 0; + switchCd = 0; + invulnTicks = 0; + midbossDone = false; + bossDone = false; + phase.set("dusk"); + augury.set(""); + second.set(0); + waveIdx.set(0); + score.set(0); + graze.set(0); + kills.set(0); + bestStage.set(1); + px.set(PLAYER_SPAWN.x); + py.set(PLAYER_SPAWN.y); + focus.set(false); + invulnOn.set(false); + activeIdx.set(0); + foes.set([]); + boss.set(null); + bossCard.set(""); + bossCardSeconds.set(0); + enemyShots.set([]); + playerShots.set([]); + motes.set([]); + fxs.set([]); + toasts.set([]); + fxTick.set(0); + bossFlash.set(-1); + wiltTicks = 0; + rescues = 0; + wilting.set(false); + wiltSeconds.set(0); + lastDx.set(0); + facing.set(1); + roster.forEach((p, i) => { + p.stage.set(1); + p.hp.set(PLANTS[p.kind].hp[0]); + p.glow.set(0); + p.spellReady.set(1); + p.spellCdTicks = 0; + p.unlocked.set(i === 0); + p.unlockedAt.set(-1); + }); + } + + function start(): void { + reset(); + outcome.set("battle"); + } + + function toTitle(): void { + reset(); + outcome.set("title"); + } + + // -- evolution -------------------------------------------------------------- + + function grantGlow(p: PlantState, amount: number): void { + const def = PLANTS[p.kind]; + p.glow.set(p.glow() + amount); + const s = p.stage(); + if (s < 3 && p.glow() >= def.evolveAt[s - 1]) { + const frac = alive(p) ? p.hp() / plantMaxHp(p) : 0; + p.stage.set(s + 1); + if (alive(p)) p.hp.set(Math.max(1, Math.round(frac * plantMaxHp(p)))); + if (p.stage() > bestStage()) bestStage.set(p.stage()); + toast(`${def.name} ASCENDS: ${def.stageNames[p.stage() - 1]}`); + fx(px(), py() - 14, "UP!", "evolve"); + sfx("evolve"); + if (p.stage() >= 2) unlock("sakura", "THE SAPLING WAKES -- SAKURA JOINS THE ROSTER"); + } + } + + // -- spawning --------------------------------------------------------------- + + function spawnFoe(kind: FoeId, stage: number): void { + const def = FOES[kind]; + const slot = FIELD.x0 + 26 + rnd(FIELD.w - 52); + foes.set([ + ...foes(), + { + id: ++idSeq, + kind, + stage, + x: cell(slot), + y: cell(FIELD.y0 - 14), + hp: cell(def.hp[stage - 1]), + hoverY: FIELD.y0 + 30 + rnd(74), + vx: rnd(2) === 0 ? 1 : -1, + fireCd: Math.round(def.firePeriod[stage - 1] * TPS * 0.6), + slowUntil: 0, + station: STATION_TICKS, + }, + ]); + } + + function spawnBoss(def: BossDef, mid: boolean): void { + boss.set({ + def, + mid, + phase: cell(0), + hp: cell(def.phases[0].hp), + x: cell(FIELD.x0 + FIELD.w / 2), + y: cell(FIELD.y0 + 46), + timeoutTicks: def.phases[0].timeout * TPS, + fireCd: TPS, + fireCd2: 2 * TPS, + spiral: 0, + born: tick, + }); + bossCard.set(def.phases[0].card); + bossCardSeconds.set(def.phases[0].timeout); + bossFlash.set(tick); + sfx(def.voice); + toast(mid ? `${def.name} BARS THE WAY` : `${def.name} TAKES THE STAGE`); + } + + function enemyFire(x: number, y: number, vx: number, vy: number, kind: EnemyShotKind, dmg: number): void { + if (enemyShots().length >= MAX_ENEMY_SHOTS) return; + enemyShots.set([...enemyShots(), { id: ++idSeq, kind, x: cell(x), y: cell(y), vx, vy, dmg, grazed: false }]); + } + + function aimedAt(x: number, y: number, speed: number): { vx: number; vy: number } { + const dx = px() - x; + const dy = py() - y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return { vx: (dx / len) * speed, vy: (dy / len) * speed }; + } + + function dropMotes(x: number, y: number, count: number): void { + for (let i = 0; i < count; i++) { + if (motes().length >= MAX_MOTES) return; + motes.set([...motes(), { id: ++idSeq, x: cell(x + rnd(17) - 8), y: cell(y + rnd(9) - 4) }]); + } + } + + // -- damage ------------------------------------------------------------------ + + /** Sakura's kindness: every damaging petal heals the most wounded waking + * form (self included) — the healer hits soft but keeps the roster alive. */ + function sakuraMend(owner: number): void { + if (roster[owner]?.kind !== "sakura") return; + let target: PlantState | null = null; + let worst = 1; + for (const r of roster) { + if (!r.unlocked() || r.hp() <= 0) continue; + const frac = r.hp() / PLANTS[r.kind].hp[r.stage() - 1]; + if (frac < worst) { + worst = frac; + target = r; + } + } + if (!target) return; + target.hp.set(Math.min(PLANTS[target.kind].hp[target.stage() - 1], target.hp() + SAKURA_HEAL)); + sfx("heal"); + } + + function hitFoe(f: FoeInst, dmg: number, pierce: boolean, owner: number): void { + if (!foes().some((x) => x.id === f.id)) return; + const def = FOES[f.kind]; + const eff = pierce ? dmg : Math.max(1, dmg - def.armor[f.stage - 1]); + f.hp.set(f.hp() - eff); + sfx("hit"); + sakuraMend(owner); + const p = roster[owner]; + if (p) grantGlow(p, eff); + if (f.hp() <= 0) { + kills.set(kills() + 1); + score.set(score() + 100); + dropMotes(f.x(), f.y(), def.bounty[f.stage - 1]); + foes.set(foes().filter((x) => x.id !== f.id)); + sfx("kill"); + } + } + + function hitBoss(b: BossInst, dmg: number, owner: number): void { + if (boss() !== b) return; + b.hp.set(b.hp() - dmg); + sfx("hit"); + sakuraMend(owner); + const p = roster[owner]; + if (p) grantGlow(p, dmg); + if (b.hp() <= 0) advanceBoss(b, true); + } + + function advanceBoss(b: BossInst, broken: boolean): void { + const idx = b.phase(); + if (broken) { + score.set(score() + 1000); + toast(`SPELL CARD BROKEN: ${b.def.phases[idx].card}`); + } else { + toast(`THE CARD TIMES OUT: ${b.def.phases[idx].card}`); + } + dropMotes(b.x(), b.y(), BOSS_PHASE_BOUNTY); + enemyShots.set([]); // the break clears the sky + sfx("bossbreak"); + if (idx + 1 < b.def.phases.length) { + b.phase.set(idx + 1); + b.hp.set(b.def.phases[idx + 1].hp); + b.timeoutTicks = b.def.phases[idx + 1].timeout * TPS; + b.spiral = 0; + bossCard.set(b.def.phases[idx + 1].card); + bossCardSeconds.set(b.def.phases[idx + 1].timeout); + bossFlash.set(tick); // the metamorphosis + sfx(b.def.voice); + } else { + boss.set(null); + bossCard.set(""); + if (b.mid) { + midbossDone = true; + if (broken) kills.set(kills() + 1); + unlock("primrose", "THE MOUNTAIN ANSWERS -- MOON PRIMROSE JOINS"); + } else { + bossDone = true; + if (broken) kills.set(kills() + 1); + outcome.set("dawn"); + sfx("dawn"); + } + } + } + + function hurtPlayer(dmg: number): void { + if (invulnTicks > 0 || wilting()) return; + const p = active(); + const def = PLANTS[p.kind]; + const eff = Math.max(1, dmg - def.armor[p.stage() - 1]); + p.hp.set(p.hp() - eff); + invulnTicks = HURT_TICKS; + fx(px(), py() - 12, `-${eff}`, "hurt"); + sfx("hurt"); + if (p.hp() <= 0) { + p.hp.set(0); + toast(`${def.name} WILTS`); + sfx("wilt"); + // No pilot switches itself: if no waking form is left to switch to, + // the night ends here. Otherwise the last breath opens — switch in + // time or lose the run. + const rescuable = roster.some((r, i) => i !== activeIdx() && ready(r)); + if (!rescuable) { + outcome.set("eternal"); + sfx("eternal"); + return; + } + wiltTicks = Math.round(WILT_WINDOW * TPS); + wilting.set(true); + wiltSeconds.set(WILT_WINDOW); + toast("SWITCH -- NOW"); + } + } + + // -- player actions ------------------------------------------------------------ + + function switchTo(delta: number): void { + if (switchCd > 0) return; + const n = roster.length; + let idx = activeIdx(); + for (let i = 0; i < n; i++) { + idx = (idx + delta + n) % n; + if (ready(roster[idx])) break; + } + if (idx === activeIdx() || !ready(roster[idx])) return; + activeIdx.set(idx); + switchCd = SWITCH_TICKS; + if (wilting()) { + // the last-breath rescue + wilting.set(false); + wiltTicks = 0; + rescues++; + invulnTicks = HURT_TICKS; + } + toast(`NOW PILOTING: ${PLANTS[roster[idx].kind].name}`); + sfx("switch"); + } + + function fireVolley(): void { + const p = active(); + const def = PLANTS[p.kind]; + const s = p.stage() - 1; + if (playerShots().length >= MAX_PLAYER_SHOTS) return; + const shots = playerShots(); + const add: PlayerShot[] = []; + const owner = activeIdx(); + const streams = def.streams[s]; + if (p.kind === "catnip") { + for (let i = 0; i < streams; i++) { + add.push({ + id: ++idSeq, kind: "orb", x: cell(px() + (i - (streams - 1) / 2) * 10), y: cell(py() - 10), + vx: 0, vy: -170, dmg: def.dmg[s], pierce: false, through: 0, homing: true, ret: false, hitCd: 0, owner, + }); + } + } else if (p.kind === "sakura") { + for (let i = 0; i < streams; i++) { + const a = -16 + (i - (streams - 1) / 2) * 2; // fan around straight up + add.push({ + id: ++idSeq, kind: "petal", x: cell(px()), y: cell(py() - 10), + vx: cosA(a) * 150, vy: sinA(a) * 150, dmg: def.dmg[s], pierce: true, through: 0, homing: false, ret: false, hitCd: 0, owner, + }); + } + } else { + // the gorilla: banana boomerangs — at most BANANA.max aloft, and a + // throw only leaves a hand that holds one + const aloft = shots.filter((sh) => sh.kind === "banana").length; + if (aloft >= BANANA.max) { + fireCd = 6; // hands empty — look again shortly + return; + } + add.push({ + id: ++idSeq, kind: "banana", x: cell(px()), y: cell(py() - 12), + vx: 0, vy: -BANANA.throwVy[s], dmg: def.dmg[s], pierce: false, through: 0, + homing: false, ret: false, hitCd: 0, owner, + }); + } + playerShots.set([...shots, ...add]); + fireCd = Math.round(def.period[s] * TPS); + sfx("shoot"); + } + + function castSpell(): void { + const p = active(); + if (p.spellCdTicks > 0 || wilting()) return; + const def = PLANTS[p.kind]; + const owner = activeIdx(); + if (p.kind === "catnip") { + const add: PlayerShot[] = []; + for (let i = 0; i < 9; i++) { + add.push({ + id: ++idSeq, kind: "orb", x: cell(px()), y: cell(py() - 8), + vx: cosA(-32 + i * 7) * 120, vy: sinA(-32 + i * 7) * 120 - 60, + dmg: 24, pierce: true, through: 0, homing: true, ret: false, hitCd: 0, owner, + }); + } + playerShots.set([...playerShots(), ...add]); + enemyShots.set(enemyShots().filter((sh) => { + const dx = sh.x() - px(); + const dy = sh.y() - py(); + return dx * dx + dy * dy > 70 * 70; + })); + } else if (p.kind === "sakura") { + for (const f of [...foes()]) { + hitFoe(f, 18, true, owner); + f.slowUntil = tick + 2 * TPS; + } + const b = boss(); + if (b) hitBoss(b, 18, owner); + enemyShots.set([]); + } else { + for (const r of roster) if (ready(r)) grantGlow(r, 100); + } + toast(`SPELL CARD: ${def.spell.name}`); + sfx("spell"); + p.spellCdTicks = def.spell.cooldown * TPS; + } + + // -- ticks ------------------------------------------------------------------- + + function tickWavesAndBosses(): void { + while (nextPhase < PHASES.length && tick >= PHASES[nextPhase].at * TPS) { + const p = PHASES[nextPhase]; + phase.set(p.id); + if (nextPhase > 0) toast(`THE NIGHT DEEPENS: ${p.name}`); + const at = epoch; + runEffect<{ omen: string }>("augury", { phase: p.id }, (res) => { + if (at === epoch) augury.set(res.omen); + }); + nextPhase++; + } + while (nextWave < WAVES.length && tick >= WAVES[nextWave].at * TPS) { + const stage = PHASES[Math.max(0, nextPhase - 1)].foeStage; + for (const kind of WAVES[nextWave].spawn) spawnFoe(kind, stage); + waveIdx.set(nextWave + 1); + nextWave++; + } + if (!midbossDone && !boss() && tick >= MIDBOSS_AT * TPS) spawnBoss(MIDBOSS, true); + if (midbossDone && !bossDone && !boss() && tick >= BOSS_AT * TPS) spawnBoss(BOSS, false); + } + + // Held verbs (movement, fire, focus) read the RAW held mask: a hold + // event's level track goes true at the same battle tick at every rate, so + // hold-driven tapes subsample exactly. (A one-frame PULSE of these buttons + // is NOT rate-portable — it holds for a whole batch at low rates — which + // is why the tape discipline steers the ship with holds only.) + function tickPlayer(held: number): void { + const p = active(); + const def = PLANTS[p.kind]; + const focusing = Boolean(held & BTN.SQUARE); + focus.set(focusing); + const speed = (def.speed / TPS) * (focusing ? FOCUS_RATE : 1); + let dx = 0; + let dy = 0; + if (held & BTN.LEFT) dx -= 1; + if (held & BTN.RIGHT) dx += 1; + if (held & BTN.UP) dy -= 1; + if (held & BTN.DOWN) dy += 1; + if (dx !== 0 && dy !== 0) { + dx *= 0.7071; + dy *= 0.7071; + } + lastDx.set(Math.sign(dx)); + if (dx !== 0) facing.set(dx < 0 ? -1 : 1); // mirror into the strafe, keep it after + px.set(Math.max(FIELD.x0 + PLAYER_INSET, Math.min(FIELD.x0 + FIELD.w - PLAYER_INSET, px() + dx * speed))); + py.set(Math.max(FIELD.y0 + PLAYER_INSET, Math.min(FIELD.y0 + FIELD.h - PLAYER_INSET, py() + dy * speed))); + + if (wilting()) { + wiltTicks--; + wiltSeconds.set(Math.max(0, Math.ceil(wiltTicks / TPS))); + if (wiltTicks <= 0) { + outcome.set("eternal"); + sfx("eternal"); + return; + } + } + if (fireCd > 0) fireCd--; + if (held & BTN.CROSS && fireCd <= 0 && !wilting()) fireVolley(); + + if (switchCd > 0) switchCd--; + if (invulnTicks > 0) invulnTicks--; + invulnOn.set(invulnTicks > 0); + + for (const r of roster) { + if (r.spellCdTicks > 0) r.spellCdTicks--; + r.spellReady.set(1 - r.spellCdTicks / (PLANTS[r.kind].spell.cooldown * TPS)); + } + } + + function tickFoes(): void { + const hasUta = foes().some((f) => f.kind === "uta"); + for (const f of [...foes()]) { + if (!foes().some((x) => x.id === f.id)) continue; + const def = FOES[f.kind]; + const s = f.stage - 1; + const slowed = tick < f.slowUntil; + const rate = slowed ? 0.6 : 1; + const spd = (def.speed[s] / TPS) * rate; + if (f.kind === "usagi") { + f.x.set(f.x() + f.vx * spd); + f.y.set(f.y() + spd * 0.35); + if (f.x() < FIELD.x0 + 14) f.vx = 1; + if (f.x() > FIELD.x0 + FIELD.w - 14) f.vx = -1; + } else if (f.kind === "wisp" || f.kind === "uta") { + if (f.y() < f.hoverY) { + f.y.set(f.y() + spd); + } else if (f.station > 0) { + f.station--; + f.x.set(f.x() + f.vx * spd * 0.5); + f.y.set(f.y() + WORLD_DRIFT); // the view slides forward regardless + } else { + f.y.set(f.y() + spd * 1.4); // the song moves on + } + if (f.x() < FIELD.x0 + 14) f.vx = 1; + if (f.x() > FIELD.x0 + FIELD.w - 14) f.vx = -1; + } else { + f.y.set(f.y() + spd); + } + if (f.y() > FIELD.y0 + FIELD.h + 18) { + foes.set(foes().filter((x) => x.id !== f.id)); // it drifts past the garden + continue; + } + // fire + f.fireCd -= hasUta && f.kind !== "uta" ? 1 / UTA_HASTE : 1; + if (f.fireCd <= 0 && f.y() > FIELD.y0 + 6) { + f.fireCd = Math.round(def.firePeriod[s] * TPS * (slowed ? 1.6 : 1)); + const dmg = BULLET_DMG[s]; + const shotSpeed = def.shotSpeed[s]; + if (f.kind === "wisp") { + const n = f.stage; + for (let i = 0; i < n; i++) { + const v = aimedAt(f.x(), f.y(), shotSpeed); + const a = (i - (n - 1) / 2) * 3; + enemyFire( + f.x(), f.y() + 8, + v.vx * cosA(a) - v.vy * sinA(a), + v.vx * sinA(a) + v.vy * cosA(a), + "cyan", dmg, + ); + } + } else if (f.kind === "kasa") { + const n = 3 + f.stage * 2; + for (let i = 0; i < n; i++) { + const a = A_DOWN + (i - (n - 1) / 2) * 4; + enemyFire(f.x(), f.y() + 8, cosA(a) * shotSpeed, sinA(a) * shotSpeed, "amber", dmg); + } + } else if (f.kind === "usagi") { + const v = aimedAt(f.x(), f.y(), shotSpeed); + enemyFire(f.x(), f.y() + 8, v.vx, v.vy, "mochi", dmg); + } else { + const n = 6 + f.stage * 2; + for (let i = 0; i < n; i++) { + const a = Math.round((i * 64) / n) + ((tick >> 4) % 64); + enemyFire(f.x(), f.y(), cosA(a) * shotSpeed, sinA(a) * shotSpeed, "pink", dmg); + } + } + } + } + } + + function tickBoss(): void { + const b = boss(); + if (!b) return; + const idx = b.phase(); + // sway on the quantized sine + b.x.set(FIELD.x0 + FIELD.w / 2 + sinA(Math.floor((tick - b.born) / 24) % 64) * (FIELD.w * 0.26)); + if (b.y() < FIELD.y0 + 46) b.y.set(b.y() + 0.8); + b.timeoutTicks--; + bossCardSeconds.set(Math.max(0, Math.ceil(b.timeoutTicks / TPS))); + if (b.timeoutTicks <= 0) { + advanceBoss(b, false); + return; + } + const speed = b.mid ? 66 : 62 + idx * 8; + const dmg = BULLET_DMG[2]; + b.fireCd--; + b.fireCd2--; + if (b.mid) { + // UMBRELLA SIGN: alternating spreads + a slow ring + if (b.fireCd <= 0) { + b.fireCd = Math.round(1.1 * TPS); + for (let i = 0; i < 9; i++) { + const a = A_DOWN + (i - 4) * 3; + enemyFire(b.x(), b.y() + 12, cosA(a) * speed, sinA(a) * speed, "amber", dmg); + } + } + if (b.fireCd2 <= 0) { + b.fireCd2 = Math.round(2.6 * TPS); + for (let i = 0; i < 12; i++) { + const a = Math.round((i * 64) / 12) + ((tick >> 5) % 64); + enemyFire(b.x(), b.y(), cosA(a) * 46, sinA(a) * 46, "pink", dmg); + } + } + return; + } + if (idx === 0) { + // NIGHT SONG: rotating rings + aimed triples + if (b.fireCd <= 0) { + b.fireCd = Math.round(1.1 * TPS); + b.spiral += 3; + for (let i = 0; i < 14; i++) { + const a = Math.round((i * 64) / 14) + b.spiral; + enemyFire(b.x(), b.y(), cosA(a) * speed, sinA(a) * speed, "pink", dmg); + } + } + if (b.fireCd2 <= 0) { + b.fireCd2 = Math.round(1.7 * TPS); + for (let i = -1; i <= 1; i++) { + const v = aimedAt(b.x(), b.y(), speed + 16); + enemyFire( + b.x(), b.y() + 10, + v.vx * cosA(i * 3) - v.vy * sinA(i * 3), + v.vx * sinA(i * 3) + v.vy * cosA(i * 3), + "cyan", dmg, + ); + } + } + } else if (idx === 1) { + // MOONFALL CANTATA: a spiral stream + aimed mochi pairs + if (b.fireCd <= 0) { + b.fireCd = 6; + b.spiral += 5; + enemyFire(b.x(), b.y(), cosA(b.spiral) * speed, sinA(b.spiral) * speed, "pink", dmg); + enemyFire(b.x(), b.y(), cosA(b.spiral + 32) * speed, sinA(b.spiral + 32) * speed, "pink", dmg); + } + if (b.fireCd2 <= 0) { + b.fireCd2 = Math.round(1.6 * TPS); + const v = aimedAt(b.x(), b.y(), speed + 30); + enemyFire(b.x() - 10, b.y() + 8, v.vx, v.vy, "mochi", dmg); + enemyFire(b.x() + 10, b.y() + 8, v.vx, v.vy, "mochi", dmg); + } + } else { + // THE ETERNAL NIGHT: twin counter-spirals + slow rings + if (b.fireCd <= 0) { + b.fireCd = 5; + b.spiral += 3; + enemyFire(b.x(), b.y(), cosA(b.spiral) * 52, sinA(b.spiral) * 52, "pink", dmg); + enemyFire(b.x(), b.y(), cosA(-b.spiral) * 52, sinA(-b.spiral) * 52, "cyan", dmg); + } + if (b.fireCd2 <= 0) { + b.fireCd2 = Math.round(3.5 * TPS); + for (let i = 0; i < 18; i++) { + const a = Math.round((i * 64) / 18) + ((tick >> 5) % 64); + enemyFire(b.x(), b.y(), cosA(a) * 42, sinA(a) * 42, "amber", dmg); + } + } + } + } + + function tickShots(): void { + // player shots + for (const sh of [...playerShots()]) { + if (sh.kind === "banana") { + // out, turn, home, and into the hand + if (!sh.ret) { + sh.vy += BANANA.decel / TPS; + if (sh.vy >= 0) sh.ret = true; + } else { + const dx = px() - sh.x(); + const dy = py() - sh.y(); + const len = Math.sqrt(dx * dx + dy * dy) || 1; + sh.vx = (dx / len) * BANANA.back; + sh.vy = (dy / len) * BANANA.back; + } + sh.x.set(sh.x() + sh.vx / TPS); + sh.y.set(sh.y() + sh.vy / TPS); + if (sh.hitCd > 0) sh.hitCd--; + if (sh.hitCd <= 0) { + let struck = false; + for (const f of [...foes()]) { + const fdx = f.x() - sh.x(); + const fdy = f.y() - sh.y(); + if (fdx * fdx + fdy * fdy <= 13 * 13) { + hitFoe(f, sh.dmg, sh.pierce, sh.owner); + struck = true; + break; // one body per touch; it keeps flying + } + } + if (!struck) { + const b = boss(); + if (b) { + const br = b.def.phases[b.phase()].size * 0.4; + const bdx = b.x() - sh.x(); + const bdy = b.y() - sh.y(); + if (bdx * bdx + bdy * bdy <= br * br) { + hitBoss(b, sh.dmg, sh.owner); + struck = true; + } + } + } + if (struck) sh.hitCd = BANANA.hitCd; + } + if (sh.ret) { + const cdx = px() - sh.x(); + const cdy = py() - sh.y(); + if (cdx * cdx + cdy * cdy <= BANANA.catchR * BANANA.catchR) { + playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + sfx("mote"); // back in the hand + } + } + continue; // a boomerang ignores the walls and the one-hit despawn + } + if (sh.homing) { + // steer toward the nearest target (quantized lerp, then renormalize) + let tx = 0; + let ty = 0; + let best = Infinity; + for (const f of foes()) { + const dx = f.x() - sh.x(); + const dy = f.y() - sh.y(); + const d = dx * dx + dy * dy; + if (d < best) { + best = d; + tx = f.x(); + ty = f.y(); + } + } + const b = boss(); + if (b) { + const dx = b.x() - sh.x(); + const dy = b.y() - sh.y(); + const d = dx * dx + dy * dy; + if (d < best) { + best = d; + tx = b.x(); + ty = b.y(); + } + } + if (best < Infinity) { + const cur = Math.sqrt(sh.vx * sh.vx + sh.vy * sh.vy) || 1; + const dx = tx - sh.x(); + const dy = ty - sh.y(); + const dl = Math.sqrt(dx * dx + dy * dy) || 1; + const nvx = sh.vx * 0.88 + (dx / dl) * cur * 0.12; + const nvy = sh.vy * 0.88 + (dy / dl) * cur * 0.12; + const nl = Math.sqrt(nvx * nvx + nvy * nvy) || 1; + sh.vx = (nvx / nl) * cur; + sh.vy = (nvy / nl) * cur; + } + } + sh.x.set(sh.x() + sh.vx / TPS); + sh.y.set(sh.y() + sh.vy / TPS); + if ( + sh.y() < FIELD.y0 - 16 || sh.y() > FIELD.y0 + FIELD.h + 16 || + sh.x() < FIELD.x0 - 16 || sh.x() > FIELD.x0 + FIELD.w + 16 + ) { + playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + continue; + } + // hit foes + let spent = false; + for (const f of [...foes()]) { + const dx = f.x() - sh.x(); + const dy = f.y() - sh.y(); + if (dx * dx + dy * dy <= 13 * 13) { + hitFoe(f, sh.dmg, sh.pierce, sh.owner); + if (sh.through > 0) { + sh.through--; + } else { + spent = true; + break; + } + } + } + if (!spent) { + const b = boss(); + if (b) { + const br = b.def.phases[b.phase()].size * 0.4; + const dx = b.x() - sh.x(); + const dy = b.y() - sh.y(); + if (dx * dx + dy * dy <= br * br) { + hitBoss(b, sh.dmg, sh.owner); + spent = true; + } + } + } + if (spent) playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + } + + // enemy shots + for (const sh of [...enemyShots()]) { + sh.x.set(sh.x() + sh.vx / TPS); + sh.y.set(sh.y() + sh.vy / TPS); + if ( + sh.y() > FIELD.y0 + FIELD.h + 12 || sh.y() < FIELD.y0 - 12 || + sh.x() < FIELD.x0 - 12 || sh.x() > FIELD.x0 + FIELD.w + 12 + ) { + enemyShots.set(enemyShots().filter((x) => x.id !== sh.id)); + continue; + } + const dx = sh.x() - px(); + const dy = sh.y() - py(); + const d2 = dx * dx + dy * dy; + const hitR = HIT_R + 3; + if (d2 <= hitR * hitR) { + enemyShots.set(enemyShots().filter((x) => x.id !== sh.id)); + hurtPlayer(sh.dmg); + } else { + const gr = active().kind === "catnip" ? CATNIP_GRAZE_R : GRAZE_R; + if (!sh.grazed && d2 <= gr * gr && invulnTicks <= 0) { + sh.grazed = true; + graze.set(graze() + 1); + score.set(score() + 10); + grantGlow(active(), GRAZE_GLOW * (active().kind === "catnip" ? CATNIP_GRAZE_MULT : 1)); + sfx("graze"); + } + } + } + + // body rams + for (const f of foes()) { + const dx = f.x() - px(); + const dy = f.y() - py(); + if (dx * dx + dy * dy <= 14 * 14) hurtPlayer(RAM_DMG); + } + + // motes + for (const m of [...motes()]) { + if (py() < POC_Y || Math.abs(m.x() - px()) + Math.abs(m.y() - py()) < 34) { + // magnet: above the PoC line, or close by + const dx = px() - m.x(); + const dy = py() - m.y(); + const len = Math.sqrt(dx * dx + dy * dy) || 1; + m.x.set(m.x() + (dx / len) * (220 / TPS)); + m.y.set(m.y() + (dy / len) * (220 / TPS)); + } else { + m.y.set(m.y() + 44 / TPS); + } + if (m.y() > FIELD.y0 + FIELD.h + 10) { + motes.set(motes().filter((x) => x.id !== m.id)); + continue; + } + const dx = m.x() - px(); + const dy = m.y() - py(); + if (dx * dx + dy * dy <= 12 * 12) { + motes.set(motes().filter((x) => x.id !== m.id)); + const p = active(); + const worth = p.kind === "primrose" ? MOTE_GLOW * 2 : MOTE_GLOW; + grantGlow(p, worth); + score.set(score() + 5); + sfx("mote"); + } + } + } + + function stepTick(pressed: number, held: number): void { + if (pressed) { + if (pressed & BTN.CIRCLE || pressed & BTN.RTRIGGER) switchTo(1); + if (pressed & BTN.LTRIGGER) switchTo(-1); + if (pressed & BTN.TRIANGLE) castSpell(); + } + tick++; + second.set(Math.floor(tick / TPS)); + fxTick.set(tick); + if (fxs().length > 0) { + const cutoff = tick - FX_LIFE; + if (fxs().some((f) => f.born <= cutoff)) fxs.set(fxs().filter((f) => f.born > cutoff)); + } + tickWavesAndBosses(); + tickPlayer(held); + tickFoes(); + tickBoss(); + tickShots(); + } + + // Frame-boundary rule (the subsampling contract): a battle frame either + // runs its FULL ticksPerFrame() batch or none of it — see the header. + function frame(buttons: number): void { + const pressed = buttons & ~prevButtons; + prevButtons = buttons; + + const o = outcome(); + let started = false; + if (o === "title") { + if (!(pressed & BTN.START)) return; + start(); + started = true; // fall through: the first batch ticks this same frame + } else if (o === "dawn" || o === "eternal") { + if (pressed & BTN.START) toTitle(); + return; + } + if (!started && pressed & BTN.START) paused.set(!paused()); + if (paused()) return; + if (pressed & BTN.SELECT) codexPage.set((codexPage() + 1) % 3); + if (codexPage() > 0) return; + + const k = ticksPerFrame(); + for (let i = 0; i < k; i++) { + if (outcome() !== "battle") return; + stepTick(i === 0 ? pressed : 0, buttons); + } + } + + // The lab seam (same spirit as __tidelight): read-only accessors so the sim + // can assert on the battle without parsing the component tree. + (globalThis as Record).__nightbloom = { + outcome, + second, + score, + graze, + kills, + phase, + waveIdx, + bestStage, + activeKind: () => active().kind, + activeHp: () => active().hp(), + rosterAlive: () => roster.filter(alive).length, + rosterReady: () => roster.filter(ready).length, + unlockedCount: () => roster.filter((r) => r.unlocked()).length, + wilting: () => wilting(), + rescues: () => rescues, + rosterGlow: () => roster.map((r) => ({ kind: r.kind, stage: r.stage(), hp: r.hp(), glow: Math.round(r.glow()) })), + foesAlive: () => foes().length, + bulletCount: () => enemyShots().length, + bossInfo: () => { + const b = boss(); + return b ? { name: b.def.name, phase: b.phase(), hp: b.hp() } : null; + }, + playerPos: () => ({ x: Math.round(px()), y: Math.round(py()) }), + }; + + return { + outcome, + paused, + codexPage, + phase, + augury, + second, + waveIdx, + score, + graze, + kills, + bestStage, + px, + py, + focus, + invuln: invulnOn, + activeIdx, + roster, + active, + lastDx, + facing, + wilting, + wiltSeconds, + foes, + boss, + bossCard, + bossCardSeconds, + bossFlash, + enemyShots, + playerShots, + motes, + fxs, + fxTick, + toasts, + frame, + start, + toTitle, + }; +} diff --git a/demos/nightbloom/f-kasa-1.png b/demos/nightbloom/f-kasa-1.png new file mode 100644 index 0000000000000000000000000000000000000000..ad49691bbc4065eac9d1d9a3f52cbc8e60b8e5e2 GIT binary patch literal 625 zcmV-%0*?KOP)dk@uq!B4jZXkrWHDvGNaCC<{x9 z;vcXfe)^vJapuk)?!5+^`PH{`A7{?@^Ej3%1#;9bTRVv)6tWV+wL+40%Hh^Ae+4CY_YIfy#*80UpVtIvM`^O z6_aTU0+}%Ygsj+vjq5B9pD--kL-;+)sLLD^Azl>pvN?>k#PeliS$L-lfa|EvM9%y> z2(>w{`K`H}fozWPD|2UJ2ermJsWGBL7|B^1sy~xzZ65Exr9ODpJj1L$_e4;nwqjWXgOK;cn9=dl8 z2>^K)ko|mBrMZTL?QgYIdjVWDb$jS#e^iJ8&>bO8z*OciL@NdWA1VN#G3f!|){Gs1 z3K9%L$h}>WN`X_4dTRl&InT$zGnKusz@mL2EdoFd0pT43Ap8!XNM8hukTJsu^GKVA znc+Vny#P2Dd^WgL7|{vkxrXbm7eK4i@pHc$Y5^#}wY`A|#9zldU3#~TWtOBW00000 LNkvXXu0mjf!DJGv literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-kasa-2.png b/demos/nightbloom/f-kasa-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d428002ede870dcdc2551188f5040eeed5e3f7b1 GIT binary patch literal 592 zcmV-W0 zDf6d?H(}W~6v zUAuP-GHc6VOMt7aIVjCC5CKpH(Kw?<`R7SzLTd$tc$PEAB2tKgh@~fhyVGt%|I@Bj z_VM}7VJv4{t{NmU`;0Tr0U($i{|e^V=Wid78t)IqD+5~htr!51E%b7G!Jdm*?UzEW z(BFE@2LiMWfQ%4S1b}IYQVTySW;q04=sZkkJrn#~(CN1a0CIlGoN+mTU`zrqAShdW zQk!g50GsokBo~cXT*U3B8TJ?*HCCaRpKB zQ&0vW2B6Hg-18hlKZpQWm7!HAw1fr{-3=PYH8;onM+Nl%j{JhE(fR<$T2pnutpe1% e%#GDp^<3W(o5b!it&)iV0000mQ|Z3-bh_v3`MvKs=Y~Q-1W`q7_2v}xC+6$JJL+sMquy^m z)%M_FV_Q#Q;TMkW9^2v&gd1aK=Oq1?== z`POk8fG<-g00m>pnSJpwW8LNLfI?(N-R_O4uZIK6{4%_t?v4_= z1&C=sw|?I+%pL$VCIpP7oY|uyxY%ebGMD8C0F2!P0BMBv=dmRtm}j7DSSSJTO*0$? z#lF#|K79-)4{YKbf!z&yeMJUgEOMC4^jzEabb#eUOp>bqki4e?JLS_1pIf`5aCEiG~31ZkYP( f4iH2T|1N$2{Pexe zYa{_N!4;A;@yZ#v%j~e(WFJGC3>j z@NjS|YR>`~OkR8a&}U8{qI1K}0HdDGJDN|M3CRwqqFyM9qZZCWS92lQsic{-4J*c$O1MJNeU8us8T>V$RFdIbX?jdb?bGDv{Qxx z_rk*H7@aCy%$_JU zJ1@Y$;f#0VX|jw;M{=IacNW~zi*n?KS9)&7pPlP)-k`38ZsovcB%eoO&AmDy%E176 zBm%e;n2aZu>n)S20<6`D($CR2CO0YqGAWvkpTm0)5ITBnw=4j_x1anipaSHLLRqXF xcuFV%7)Se8WTvzB2GuR4Y)*aq=QJ38$3M-W&;>GXUhMz?002ovPDHLkV1jW2@reKc literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-usagi-2.png b/demos/nightbloom/f-usagi-2.png new file mode 100644 index 0000000000000000000000000000000000000000..9ebd8f6500c5e22b3dfe19001d9a806d80ed68cb GIT binary patch literal 497 zcmVY6ujSLAy_FGOd&RcLLebUz#rjHu+zeSA=ugoicQLpuO?uKVlapXgfv&I%;JQX z=bdJ6cVm-%AO6wi?jS^(z=r(|I_WdE~f zfjN-p0*`3tuY<8$r2iT`A)TB6F$Az`SWKJs35d4n)@T6;Dd)k|Es(=CJU(680i+~y z#ED!kebsV7&~$X|d9Zk98e%hf<&stMxdcy6F99TW0m*r4dJDkUASa%-=7E(%NRa+x n2<7GDuTAlu|MezcuT`us$rRbCyxkpG00000NkvXXu0mjfs+rd% literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-usagi-3.png b/demos/nightbloom/f-usagi-3.png new file mode 100644 index 0000000000000000000000000000000000000000..2755efa0494749238047c63f2b36a076f694e48d GIT binary patch literal 503 zcmVw%3RVlEzZR!ot!bO&YNhG?iG0sEyc&ATbz4 z1qrA)@)q7PuAAfT_U>%5FdQMvzTeK*PN_6lDb_+|PO4+G@^9k3_nt8uf?)XOD*k=& zXueuVgSp@`1-J{2=5TmfmJDCDNoB$A}c%cBd z=Qp8%&Wl0;7-JhC7jX*F{n`UaMEmx+Re%iu(*U`N6X2?KW|B{roKDQ=`*wa!NN2lk z0E4kFc&&PQ#2m6&u7ro-VAsuE<_+;Sy&{14nhE~Yd0a~k0kMzjx&VRzcAJf32uG@1 zfM9!SS%$aP1R=n0xIV7?GkJ_aKGfKi(Z-Is1;m#oYGK*guBK%Mz_?S8w|9y36Q^-L zNDRZoi#j|>J|u3>*!=SJ8UomTUWdZ`*X%?>Y)l5p03dw4e<mC8hUyiw{;2>j(Q)+13E>=57E0002ovPDHLkV1hL?>23f3 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-uta-1.png b/demos/nightbloom/f-uta-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9f0eb2484d28261fb731639868694c58c6c19e80 GIT binary patch literal 632 zcmV-;0*C#HP)5XRG4T3Eyw4GIb|s6`YX-~%5BBB)pxL`6+3Qiv!%3K5kk7Gf*dsc2;-79v;# zjbQK(5VR7zlr}y1N?zh-&%Di~s@pg90!EkVctlqD*pnnz|f#QSnbnK1~;Oh{`+0zh_@ zISk2EHa7);n6DjX2Z2SQRqv2!#INfKc0IMCFTEms$1nr{U`haVBUI;Vbct_^+f^#e zdJ3C#72#E|JB$IK%ycvPCIo=BubX=IwpYZB0E9K&He>?;6=%{3zEVSW@`=GjE&cDO#CF8Q@;tO5`O zjcvxy2K)%-0KwxJXTtZ(tx)Xrf(!3cLCDv~zJ<&xg1P4a+WulHWB^sNVKN{jkr6;k zaUBPslMt2o3qY-eycK}YxFM+m*b19=NTmaS_B#VQ8E!^QfqV#|D(L{w7s!}oY82H# zC%+z^r3D}eQ)8GakWnfKGKVZNr|`Hu31+eYhjYRbV|u83oQ$*Qk*?nnwL21CvHm#G zZ_VF0dK|+bS&2~@{CBVnfM=;m{S1cZgMM~2FrD4b|8D?b&h6&1v;p&x?f3!k5o|U6 StmpFp0000RHi1J_sR$x?6P#%h+s3=59f+PY(#7mcmqzIu)M^DvFIz&Z99~49o)X@&n zp^syi4s{PCgb;J$nS7UN9kq4Wyg2Z%yXd~p@BNrjMX9dpDzJQ=1FRy(tri<=scQctx0mx+1s$>AOT|?|4vLO{d zGYlX?XaQjGw7ixh5u&B8u`~!R0NoQnxYsE)Hw{3^4q?(4`WZrB=+_Y5g`;eJ#G7NR zg;*W*7#m(>03(P3A$tIzGHngb-0*oJ%I~!hh5+y)g5WBak^pcOe2j8Xu!uL!C zKmaNyo?%Y?Pn~aXF7TkX6J;!NN@Q)Yky zQQ()uOZN48&kd;}IIgt-s06A2fR*71F5SC=0O*RKFelyapQ+k)1*E$I5KdbM7oK%N z;L9LKE076FF9K+FaV0I z7~_b3AL}!>_e!?(sjC?-oc}TdRA$p{VKKL>JXN4fW#%wsTlFk4u9Ua`h2T(uiu{eQ bE+gw3ux4RX>x}QA00000NkvXXu0mjfRVf16 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-uta-3.png b/demos/nightbloom/f-uta-3.png new file mode 100644 index 0000000000000000000000000000000000000000..a439d880c8dac2474f19e6f99c5a34f43abb053a GIT binary patch literal 523 zcmV+m0`&cfP)G1fs3PpSP>m`Dz%&9U^ge79P1*Yi-W>_0>6OJ)2Ve7 z7m2)-cZ8UvUec?}{os+@pX9y#{@oP{x#i}t#_XE-7vkFVia45B6z@030&`&K0=l>L z1Vi-%PEO*_ZYP=JzW(k^aZH#n5CI@3OaegkRIUBOXFT)2?LsD$NWB{>lU+D@;-+Y1nY%qJj#_aOkDQ9WS`Nl-#!G03?I zMvw!Tziw@ax4mtdaJnD>A@CM}g=&16)fh?e{d^*cqw0Ztu09)x0D6EMss#6ES0X+g z5plCDM+)T#a@Bj@BDj%yvak3>Je*%k5rC}>^-{zwiNK5Znb# zh7CJAm0tU8mW`+6hY9ax5(3wMAHvdJ8WrusQnvAT8AFs=4&;`F{Q#*WTVSMM5*+{l N002ovPDHLkV1lzy_|Ym2__xAs<7-XAG$8|#e_1Q3Hb?FKSHEY1N^Nw`*CHY3}N zm;E7l!H{DqhyZemV+!tw?;nN}dHl|^>D(KO;x)AeNrU$RAgcfnL~aWxepLYSeX;V! zS<}qv9>p&PaH4+30QkMoe!$c0BpR$s3(%ow#7U$+L;Mo(z0QSs61jUo$fDw=Wl_3< z{2U=v7Geybt@rAGM~ok5&3n#|Dgd#*=2+YYDS%1}I8dt4TL40e^;zX#P*n6+dIB#a V-GZJha;yLV002ovPDHLkV1mTXi!cBH literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-wisp-2.png b/demos/nightbloom/f-wisp-2.png new file mode 100644 index 0000000000000000000000000000000000000000..4092f8bb2e6f26e781d489307319ed3b15462705 GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJi=HlyAr*6y6C_v{Cm1xxTSxrk z-y&^rpkKfGpYr4T*3%Cd-qrd4{lC4$)0p*q@NV0u-}M`Y%<)m^h8pK_XLB(>~|Oq+ykUoMJyTG>dnlW4Yy5bI2pLr zr$MsiG^2r;BKv}NHP=1`Rjv&$m9R9(s{2Ofj#3H5kAVhUZj5>i41#>8{TR=dO9MT} N;OXk;vd$@?2>^JNV#NRe literal 0 HcmV?d00001 diff --git a/demos/nightbloom/f-wisp-3.png b/demos/nightbloom/f-wisp-3.png new file mode 100644 index 0000000000000000000000000000000000000000..30e218d5b94a081f953bea63df498256f09e653a GIT binary patch literal 315 zcmV-B0mS}^P)B+cxGA<1wH`>cCnXm{m`iDui)jCWDy5qUrv}Do|E%ec>;S(yMr=_=GOoK N002ovPDHLkV1hGdfztp0 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/gen-assets.ts b/demos/nightbloom/gen-assets.ts new file mode 100644 index 0000000..9bcfa51 --- /dev/null +++ b/demos/nightbloom/gen-assets.ts @@ -0,0 +1,171 @@ +// demos/nightbloom/gen-assets.ts — NIGHTBLOOM's content pipeline: every unit +// sprite, projectile and backdrop is generated by the PixelLab pixel-art API +// (pixellab.ai) from the seeded ART manifest in data.ts, then normalized into +// the pak-safe PNG subset. Same contract as demos/tidelight/gen-assets.ts: +// +// bun demos/nightbloom/gen-assets.ts # generate missing assets +// bun demos/nightbloom/gen-assets.ts --force # regenerate everything +// bun demos/nightbloom/gen-assets.ts --only=p-catnip-1.png +// +// Auth: PIXELLAB_API_KEY in the repo root .env. Every entry pins a `seed`, so +// a re-run reproduces the same art; generated files are committed, which makes +// the script a cheap no-op unless an asset is deleted or --force is passed. +// +// Evolution stages are init_image chains (stage II derives from stage I, +// III from II) so a creature keeps its identity as it ascends — the same +// identity-preserving trick tidelight uses for portrait moods, applied to +// a whole bestiary. +// +// Output discipline: replies are decoded and re-encoded through the exact +// canonical subset the pak build accepts (colorType 6, bit depth 8, filter 0, +// single node:zlib IDAT) so committed bytes stay deterministic. + +import { existsSync, readFileSync } from "node:fs"; +import { deflateSync } from "node:zlib"; +import { decodePng } from "../../compiler/pak.ts"; +import { ART, validateContent, type ArtEntry } from "./data.ts"; + +const HERE = new URL(".", import.meta.url).pathname; // demos/nightbloom/ +const ROOT = new URL("../..", import.meta.url).pathname; // PocketJS/ +const API = "https://api.pixellab.ai/v1"; + +// --------------------------------------------------------------------------- +// Canonical PNG encoder (identical subset to demos/tidelight/gen-assets.ts, +// validated against compiler/pak.ts decodePng) +// --------------------------------------------------------------------------- + +const CRC = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } + return t; +})(); +function crc32(buf: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } +function u32be(n: number): Uint8Array { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, false); return b; } +function chunk(type: string, data: Uint8Array): Uint8Array { + const tb = new TextEncoder().encode(type); + const body = new Uint8Array(tb.length + data.length); body.set(tb, 0); body.set(data, tb.length); + const out = new Uint8Array(4 + body.length + 4); + out.set(u32be(data.length), 0); out.set(body, 4); out.set(u32be(crc32(body)), 4 + body.length); + return out; +} +function encodePng(w: number, h: number, rgba: Uint8Array): Uint8Array { + const stride = w * 4; + const raw = new Uint8Array((stride + 1) * h); + for (let y = 0; y < h; y++) { raw[y * (stride + 1)] = 0; raw.set(rgba.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1); } + const ihdr = new Uint8Array(13); const dv = new DataView(ihdr.buffer); + dv.setUint32(0, w, false); dv.setUint32(4, h, false); ihdr[8] = 8; ihdr[9] = 6; + const idat = new Uint8Array(deflateSync(raw)); + const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const parts = [sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))]; + const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); + let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; +} + +// --------------------------------------------------------------------------- +// PixelLab client +// --------------------------------------------------------------------------- + +function apiKey(): string { + const env = process.env.PIXELLAB_API_KEY; + if (env) return env; + const envPath = ROOT + ".env"; + if (existsSync(envPath)) { + const m = readFileSync(envPath, "utf8").match(/^PIXELLAB_API_KEY=["']?([^"'\n]+)["']?$/m); + if (m) return m[1]; + } + throw new Error("nightbloom: PIXELLAB_API_KEY not set (repo .env or environment)"); +} + +async function generate(key: string, asset: ArtEntry): Promise { + const body: Record = { + description: asset.prompt, + image_size: { width: asset.w, height: asset.h }, + text_guidance_scale: 8, + no_background: asset.transparent ?? false, + seed: asset.seed, + }; + if (asset.direction) body.direction = asset.direction; + if (asset.shading) body.shading = asset.shading; + if (asset.detail) body.detail = asset.detail; + if (asset.initFrom) { + const initPath = HERE + asset.initFrom; + if (!existsSync(initPath)) throw new Error(`nightbloom: init image ${asset.initFrom} missing — the manifest orders bases first`); + const bytes = new Uint8Array(await Bun.file(initPath).arrayBuffer()); + body.init_image = { type: "base64", base64: Buffer.from(bytes).toString("base64") }; + body.init_image_strength = asset.initStrength ?? 300; + } + + for (let attempt = 1; ; attempt++) { + let res: Response; + try { + res = await fetch(`${API}/generate-image-pixflux`, { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch (err) { + if (attempt <= 3) { + console.log(` network error (${err instanceof Error ? err.message : err}), retry ${attempt}/3 in 5s ...`); + await Bun.sleep(5000); + continue; + } + throw err; + } + if (res.status === 429 && attempt <= 3) { + console.log(` rate limited, retry ${attempt}/3 in 5s ...`); + await Bun.sleep(5000); + continue; + } + if (!res.ok) { + const detail = await res.text(); + throw new Error(`nightbloom: pixellab ${res.status} for ${asset.name}: ${detail.slice(0, 300)}`); + } + const json = (await res.json()) as { image: { base64: string } }; + return Uint8Array.from(Buffer.from(json.image.base64, "base64")); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const problems = validateContent(); + if (problems.length > 0) { + throw new Error(`nightbloom: content tables are inconsistent:\n ${problems.join("\n ")}`); + } + + const force = process.argv.includes("--force"); + const only = process.argv.find((a) => a.startsWith("--only="))?.slice("--only=".length); + const key = apiKey(); + + let generated = 0; + let skipped = 0; + for (const asset of ART) { + const path = HERE + asset.name; + if (only && asset.name !== only) continue; + if (existsSync(path) && !force && !only) { + skipped++; + continue; + } + const t0 = Date.now(); + const png = await generate(key, asset); + const img = decodePng(png); // validate against the pak decoder's subset + if (img.width !== asset.w || img.height !== asset.h) { + throw new Error(`nightbloom: ${asset.name} came back ${img.width}x${img.height}, wanted ${asset.w}x${asset.h}`); + } + await Bun.write(path, encodePng(img.width, img.height, img.rgba)); + generated++; + console.log(` generated ${asset.name} ${asset.w}x${asset.h} seed ${asset.seed} (${((Date.now() - t0) / 1000).toFixed(1)}s)`); + } + + const bal = await fetch(`${API}/balance`, { headers: { Authorization: `Bearer ${apiKey()}` } }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null); + console.log( + `nightbloom: ${generated} generated, ${skipped} kept` + + (bal && typeof (bal as { usd?: number }).usd === "number" ? ` (pixellab balance: $${(bal as { usd: number }).usd})` : ""), + ); +} + +await main(); diff --git a/demos/nightbloom/main.tsx b/demos/nightbloom/main.tsx new file mode 100644 index 0000000..07c9f7e --- /dev/null +++ b/demos/nightbloom/main.tsx @@ -0,0 +1,9 @@ +// @title PocketJS: Nightbloom +import Nightbloom from "./app.tsx"; +import { installAugury } from "./backend.ts"; +import { installSfx } from "./sfx.ts"; +import { mount } from "@pocketjs/framework"; + +installAugury(); +installSfx(); // no-op on hosts without an audio device +mount(() => ); diff --git a/demos/nightbloom/mote.png b/demos/nightbloom/mote.png new file mode 100644 index 0000000000000000000000000000000000000000..a4209e59bff3b6c581868b760e9f556f3c4f8df8 GIT binary patch literal 398 zcmV;90df9`P)abjH5uY=Xu&F@}5!s};O`QQ~6&i2o7h0!UT`7NNAtmVJ%^WGr)( z?6nktJ$%+MHg*SyjoWXFN^St!U0mTVnk)sn%yoWeFvaM>)}AmX&YS3 s6=bI~VWoK>TmfQ`Klvofk>g**9yeLr<*r|;MqlHNmB_qFwXPKS6O`h*VL~`Nq=FPmh=gggZ@AJI>cGvCcEPJovg2j$E+kBa>r2Px9v5n`Of@OvoWu-$+CNMi_%m zuaZ;E+fW12dg20b%4vBn%{=-;1ms$8&$Pr7;9WN)H@N@=Tg8W_wX>sXgklT8iJJpI zj_2zHj9mdlfcdWOo_GS(UUd!nC*b__bJ6@dKl8^yIlww_+jrzBfNXB8Aqp-zgqlMH z-^V7mP4|v-{eXf_uN;U>{-W~EfM)B8Mi63`p2!6fFz7qG5)!F*o1BDGGa<}wC=nHO z`q%9~(mRpC_Ra=_MFxXG7$jK?GGSz*#3E*85TO)F zN1xmM-j0KM-Fx4Xocea&d(V5H@8>+{Ip=uZfBWsNwxQjiDBm`<^%*IWd0Xd*c{{)G z0m`fDvJS$35#PNYpPKsSu4n)qJ!9rQ<9~~{Mgy#H4tT|(nOaXX&dI_IZ8$j4~lXYVjGTob*57$_QClGoJF_(bhJjI^|1az{LU(gf17du$r#_WR7Ggh@W0!Swjc}Y3Ay6Kmiiz0P=!kSVyWmrobxB zuXaGJb(gvH-5R=;uA+==?3y!sQpx4x2Ig7Ut`xi4Z2+Yhxl6&0u*HFMv(sqSGDYz- zKma=k02CZXp0y?IHGkM`H76^@W_m0?nJwow%~ZulHUy#5aWEl8KxEc)_;Am>ckFyW_(-fH^gQat7c9MaXf>OfjVZ*#SUd pzLY7*Edb4)DIjDc=ca$Uy#pl}_s3`ILazV-002ovPDHLkV1fkxBWC~r literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-catnip-3.png b/demos/nightbloom/p-catnip-3.png new file mode 100644 index 0000000000000000000000000000000000000000..16d96f68e4b0d998a3ea453bd92e5e5d39e577d0 GIT binary patch literal 645 zcmV;00($+4P)iW()2HWDQkM+&22kf4PIj06i)M6k24k<=kgijYRpYeYXl%JfMQ zh>c=lp!bhih@seLdmath z`0Tr==aZ@VRl7VmvBAMHds>;Xh1K`BG9+kJ|8Zp4i3a$&xtoeNc{a6VLrCKT<}&Be0N%jC&VDLFiEu(h@+1H~TM7VDB)Yl>G7u!k({I+YIiBUeK$iwUEqwn{ zZEzlf=$cv~3Id2K!Vtix0P$8~6#iUXHq4{p(doDX^Z>j$hC_KrIG6kyH4z2Cpz4*w zJf~qqaGqBm(E#KTh0eSB`|M$FcPgKIFTWlQ;I~_7`26r=`U?zs_Bm(n>)L~Cy$}Gx zs6a62BOpht@js+BDH^~-c!1jBMnfc+6Q}@As{>X)`&29fVHBtb3P_4}=i(03rZHo^xLx>>d=|1+RlpLqgYWN$s2>Z9MDa;UVJZZ)!$`4j-K8 zEfLKp?|<42epeJOIPY8^AbAkn>mvnm4M6m{?*Yi4&o_CU82)eAZ#SIRYk%!{tJglf zZYc-A=TF~r+DhTU#Qg7XFKm0ItsHv^00000NkvXXu0mjfdU+@w literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-primrose-1.png b/demos/nightbloom/p-primrose-1.png new file mode 100644 index 0000000000000000000000000000000000000000..548c66f53c1da13681de9bfe762f9134cb87518f GIT binary patch literal 682 zcmV;b0#*HqP)4akdj$06i1w#&=Dq>!OTHRGZ;`2bCI|RhZZ4>q%BJDBMOwDKyf8u(IRru zCU-_Ih1%s#Auvc6eo*^Yo)@0Wnd=>4oO34xFFed;W;oC9{W$MAj&uH;^0^VNmg}J^ z!Fxx;RVg~awabpAGmg}il*sLPWAOkGHB?_&A?>#=NjP#*ijskcsiaUa?7taP2#8RD znWNsHW}d1D(H)Owi6DUijP`ftG^YZ|1R}%YNO>+}X29GTRssHen3kO{uUv?KYm2Jw z`Nys~OJKDWTyVCkMgb027hDa8!t(2NMj;3QZOuHAcdbd8h{p6sd(qY+n#6oNUM z6|!n>LLmL7ICx-+s4<|Pr~6daw&6fqB5RAm2x^Yh>d@JEF{A>F-#Jkb2o_tK9C7Ok zBh(+!vlq+(WvbxOj0j;40R+*~6aZ!unv;-G`;F+l0hm%QJlB7)nOFkD8a>KpzKz?prH0F&j`$}`lY QJOBUy07*qoM6N<$g08+haR2}S literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-primrose-2.png b/demos/nightbloom/p-primrose-2.png new file mode 100644 index 0000000000000000000000000000000000000000..7445b0edbb41d4d7fbcba415c67db1299c749eab GIT binary patch literal 718 zcmV;<0x|uGP)DMW^hm?mw;Q7T@IL_e%#HkNIgs1O%Jx)MW)KuT#5QqT`v6=dsH zEn2jRtX10@F?p@a{;GhX z@-{;Rs2tC`t8lKL&WRv zo;hA$A9wQZ?U@F7R1u$7W}M*O=&(Jwc-D9apO&X=b!^nZI>!PjIkEn6)z_Y$Ik9;W z7{Tz%`X%@BWKyy3Yb#bR78}X;VQInz0HSg%yW7{J3c&0AUAvqZ51Bze^=aKUxzQ#Lb2n0lAexMXL5izPn#ABdD{PJSX z?w(3KQW6oBTmz!Te5cLkci5BCUMGKl=ZZZ_%8h)8UV<8AcYS!!y?uMD=6HBHvFW3Q z265OEKonxS9DtnAa<(Gm9q%bokX2W+Yg!x7eXy6Z8g6Rzo```u8IVL1K6q0$R$Kl*(ThJT|o zRp}{DqDMgI{j1v!0qP&o1^kJ?FgLdBO8zIu4-zNCrmH=Cj{pDw07*qoM6N<$g5h#b An*aa+ literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-primrose-3.png b/demos/nightbloom/p-primrose-3.png new file mode 100644 index 0000000000000000000000000000000000000000..57636c907502e3c1414edbd7885563d0d8d4ef47 GIT binary patch literal 650 zcmV;50(Jd~P)n}BA6nGF2p1VVp=FIL`5Q$Tu3d-g)Jrm-3V?31;JJ> z3~H0$A}EL!wg{mw5Yg(y@5SfvavOs?Gp9BlcsO^?x%WKJ`}f`{6i!DKilf5%)W;1pomSLpt8o5rKeYo@hPa7$iVs2;jH8AYnq?1sv|K86@P}yDbaH z!SdW6)=wAP4K))#8MqY=p3d5#&pW%r=ZzQP)B2L(!|GyqU!FGscthlXX8`Lp3Gi`; zkQr|vi{G1M%8F?+Mih$Nu*_FVY7Qw}f7+qOQ?eaF18H8O1bt3!iE zzIkt~J^~8I_5bk7Rg3%EXESluwFDpt4-vW>4d?2chzQu5EE%Cd4uF-uezQDzLqY&h zj7dh4lj|hq=Z9*2ccK(Qez1<)eXmfTs4Kaup%O&w;oEdg5f?e zzD0wo$VXc*$3w7`>{bWV{N>eB6yV#jL`Zn<#DtuKe6Oe6K&*oKsR-^@M=TtdZq^IA zM2OtK?{T@eUZ^BM&xt&@0B%<{9 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-sakura-1.png b/demos/nightbloom/p-sakura-1.png new file mode 100644 index 0000000000000000000000000000000000000000..2f555517fafeae823a6cc027e7d0045c520c259e GIT binary patch literal 533 zcmV+w0_y#VP)NU+H}BSbKKGnE_sv*VaTG^^BQ?K8$ghJF&Ke<)o5!5w z)Cxx(%qyPRaKUCEa2QTEP!iY_X2Oc$vC}+55BBgs)FFX zw88hyh!(IwCQHeRX^u!-wMDoJm_r*Nj91?U0N{B0{{hA(#p>mp@KwMv&H;c-frrE; zw@9J_H;0^jatPtOFJKk;pwmi-_r4uyq>)gU3sze>AiWb!Zp{0L3e+4}eK^|B!L>-B z0_yO55>g2fz?@VgU#rw4AP`#bap5wj+8WuANc%u7iwIa+qyqL1tX}qlDWwYSEUP97 zN)G|2zK^2{HsQ-H!yE{p$2{`1v+ZUe5R@3>y*9wW>Z4E*Up+5hB^$-f!z Xp(=6Pk3fcf00000NkvXXu0mjfHn8u# literal 0 HcmV?d00001 diff --git a/demos/nightbloom/p-sakura-2.png b/demos/nightbloom/p-sakura-2.png new file mode 100644 index 0000000000000000000000000000000000000000..701ac06228094401bb6d41008a856ed0b5050c5d GIT binary patch literal 568 zcmV-80>}M{P)2)BAqTx#!NkHrs#v#}DIpe21XDK!kqHJlYjz9S*Ov z)Quyq5fXFJ$-oxjeW;*E7XW70i&1(wx{cAr^gcsCtNDF85W7`Vob7vav!og-5CCu_ zrjau_^Gks3re$6V%a;QmFG|g(r{@=HFKmc0_GXsS{r-&saZnMo9snJFw{pr5Vg_N3 zsz_y|F_-)k)-S`^;vt)yd*%&*%g3u20QA6&caS1&LF)me2~mMSUJZ}eirNfw>=53P z8s-vMUu-*b4*&>St(_%QNfp5Mwj+=!`T|dxLz}Ka>&@%>>{L>l^5MhH15T>%&S3yl zX2Cb4lQ{q;!)1gg^c|SxkyR2$E?+XuZMexFbP(q9UftXbfhrW%#4`-$W&n)eSIF|n zbte#nDhPy_qZa@O;Wug!2&}ug3N52{!yFY-HJ6aSfGM6Lbz}VN;Z$_Dr&C5!0*@he zW1M*X<(;gtVn4|iY;;z$}M{P)}fEa$HJ`Lu|0h0bRj$Ge$))FYQl#=K# z0HgzrSC6oi0DOIX{xVL-?&&GG&FLhgH>`Pkz+8Z*p?ymCtx=WLYJk9NffE2cmPhV& z^N$0Y!vv-R0GK6Tm9~cW;OQ{kTmZzlp|XZy!tNh)ef)c#Wp%@dN#{;iZ+{fMq155C z27n<{W-fIZIpH&2Yz?t+Eg#9IPd{op=%T)1o3=3w5p!)*J_~D@dRaFeU}CO=Vq1gc z_0<_RclKDQzO6ugEDk&;r37|3n{ci%7y-_oUnbbGR(?81?9?EP00000 + notes.map((f, i) => ({ f0: f, f1: f, dur, type, gain, delay: i * step })); + +const SFX: Record = { + // the trigger finger — quiet, constant, felt more than heard + shoot: { voices: [{ f0: 900, f1: 640, dur: 0.03, type: "square", gain: 0.03 }], throttle: 45 }, + // 打击音效 — the thock of a shot landing + hit: { + voices: [{ f0: 250, f1: 150, dur: 0.055, type: "square", gain: 0.1 }], + noise: [{ dur: 0.035, gain: 0.08, cutoff: 900 }], + throttle: 40, + }, + kill: { + voices: [{ f0: 720, f1: 170, dur: 0.15, type: "triangle", gain: 0.16 }], + noise: [{ dur: 0.07, gain: 0.12, cutoff: 1400 }], + throttle: 60, + }, + hurt: { + voices: [{ f0: 260, f1: 65, dur: 0.24, type: "sawtooth", gain: 0.26 }], + noise: [{ dur: 0.14, gain: 0.18, cutoff: 500 }], + }, + wilt: { voices: [{ f0: 440, f1: 110, dur: 0.45, type: "triangle", gain: 0.2 }] }, + graze: { voices: [{ f0: 2300, f1: 2300, dur: 0.02, type: "sine", gain: 0.05 }], throttle: 70 }, + mote: { voices: [{ f0: 1300, f1: 1560, dur: 0.05, type: "sine", gain: 0.055 }], throttle: 50 }, + switch: { voices: ARP([520, 780], 0.06, 0.05, 0.1, "square") }, + spell: { + voices: [ + { f0: 250, f1: 1400, dur: 0.3, type: "sawtooth", gain: 0.16 }, + { f0: 1800, f1: 1800, dur: 0.15, type: "sine", gain: 0.07, delay: 0.1 }, + ], + }, + evolve: { voices: ARP([523.25, 659.25, 783.99], 0.07, 0.12, 0.12, "sine") }, + unlock: { voices: ARP([659.25, 880, 1174.66, 1567.98], 0.09, 0.2, 0.13, "triangle") }, + heal: { voices: [{ f0: 880, f1: 1320, dur: 0.07, type: "sine", gain: 0.05 }], throttle: 260 }, + // the diva's cry — three quick chirps, up, down-up, and away + "boss-bird": { + voices: [ + { f0: 2000, f1: 2750, dur: 0.07, type: "sine", gain: 0.16 }, + { f0: 2500, f1: 1650, dur: 0.09, type: "sine", gain: 0.14, delay: 0.1 }, + { f0: 2300, f1: 3100, dur: 0.1, type: "sine", gain: 0.15, delay: 0.21 }, + ], + }, + // the umbrella's cry — a beating metal clang over a low whoomp + "boss-umbrella": { + voices: [ + { f0: 196, f1: 180, dur: 0.4, type: "square", gain: 0.14 }, + { f0: 203, f1: 186, dur: 0.4, type: "square", gain: 0.12 }, + { f0: 90, f1: 45, dur: 0.35, type: "sawtooth", gain: 0.2, delay: 0.02 }, + ], + noise: [{ dur: 0.12, gain: 0.14, cutoff: 3200 }], + }, + bossbreak: { + voices: [{ f0: 160, f1: 40, dur: 0.5, type: "sawtooth", gain: 0.28 }, ...ARP([659.25, 783.99, 1046.5], 0.09, 0.16, 0.1, "sine")], + noise: [{ dur: 0.3, gain: 0.22, cutoff: 700 }], + }, + dawn: { voices: ARP([523.25, 659.25, 783.99, 1046.5], 0.12, 0.4, 0.13, "sine") }, + eternal: { + voices: [ + { f0: 110, f1: 55, dur: 0.9, type: "sawtooth", gain: 0.26 }, + { f0: 220, f1: 110, dur: 0.7, type: "triangle", gain: 0.18, delay: 0.05 }, + ], + }, +}; + +const MASTER_GAIN = 0.6; +const MAX_VOICES = 14; + +export function installSfx(): void { + const g = globalThis as Record; + const AC = (g.AudioContext ?? g.webkitAudioContext) as (new () => AudioContext) | undefined; + if (!AC) return; // headless sim, PSP: no audio device, no sink + + const ctx = new AC(); + const master = ctx.createGain(); + master.gain.value = MASTER_GAIN; + master.connect(ctx.destination); + + // one shared noise buffer for the impact texture (xorshift so even the + // speaker noise is reproducible — the repo's habit, kept out of habit) + const noiseBuf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * 0.3), ctx.sampleRate); + const data = noiseBuf.getChannelData(0); + let n = 0x6e696768; // "nigh" + for (let i = 0; i < data.length; i++) { + n ^= (n << 13) >>> 0; + n = n >>> 0; + n ^= n >>> 17; + n ^= (n << 5) >>> 0; + n = n >>> 0; + data[i] = (n / 0xffffffff) * 2 - 1; + } + + let live = 0; + const lastPlay = new Map(); + + const spend = (): boolean => { + if (live >= MAX_VOICES) return false; + live++; + return true; + }; + const release = (): void => { + live = Math.max(0, live - 1); + }; + + function tone(v: Voice): void { + if (!spend()) return; + const t0 = ctx.currentTime + (v.delay ?? 0); + const osc = ctx.createOscillator(); + const env = ctx.createGain(); + osc.type = v.type; + osc.frequency.setValueAtTime(Math.max(1, v.f0), t0); + osc.frequency.exponentialRampToValueAtTime(Math.max(1, v.f1), t0 + v.dur); + env.gain.setValueAtTime(v.gain, t0); + env.gain.exponentialRampToValueAtTime(0.0001, t0 + v.dur); + osc.connect(env); + env.connect(master); + osc.onended = release; + osc.start(t0); + osc.stop(t0 + v.dur + 0.02); + } + + function burst(n: NoiseBurst): void { + if (!spend()) return; + const t0 = ctx.currentTime + (n.delay ?? 0); + const src = ctx.createBufferSource(); + src.buffer = noiseBuf; + const lp = ctx.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = n.cutoff; + const env = ctx.createGain(); + env.gain.setValueAtTime(n.gain, t0); + env.gain.exponentialRampToValueAtTime(0.0001, t0 + n.dur); + src.connect(lp); + lp.connect(env); + env.connect(master); + src.onended = release; + src.start(t0); + src.stop(t0 + n.dur + 0.02); + } + + g.__nightbloomSfx = (kind: SfxKind) => { + const def = SFX[kind]; + if (!def) return; + if (ctx.state !== "running") return; // pre-gesture: stay silent + const now = performance.now(); + if (def.throttle) { + const last = lastPlay.get(kind) ?? -1e9; + if (now - last < def.throttle) return; + lastPlay.set(kind, now); + } + for (const v of def.voices) tone(v); + for (const n of def.noise ?? []) burst(n); + }; + + // The lab seam: lets a harness confirm the device state without hearing. + g.__nightbloomSfxState = () => ({ state: ctx.state, live }); + + // Autoplay policy: resume on the first (and any later) user gesture. + const doc = g.document as { addEventListener?: (t: string, cb: () => void) => void } | undefined; + const resume = (): void => { + if (ctx.state === "suspended") void ctx.resume(); + }; + if (doc?.addEventListener) { + doc.addEventListener("keydown", resume); + doc.addEventListener("pointerdown", resume); + } +} diff --git a/demos/nightbloom/shot-banana.png b/demos/nightbloom/shot-banana.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3e41e85e9bc777d04dc3e64b80e22fc48ad796 GIT binary patch literal 306 zcmV-20nPr2P)WXjxh&><<`1Ae#z=Q(GGEn>@J78zM5d9nh@;x>O&?PEBzP!8vWcf?D<#clZDC^zd z!vpsD(WT7vbpXicAd5jhN4I=$i!k*HB#=cQpTIDP55gdy@1M^E#>ke#d{2=BK&2T7 zLwxxXD@OMRu=_nWlqktX&0I009cNSv<5dZ)H07*qoM6N<$ Ef^H#u2LJ#7 literal 0 HcmV?d00001 diff --git a/demos/nightbloom/shot-mochi.png b/demos/nightbloom/shot-mochi.png new file mode 100644 index 0000000000000000000000000000000000000000..1e5decbfee0d757e82f8ae0669429b37b37e0346 GIT binary patch literal 307 zcmV-30nGl1P)}{>Z>i}d2g49#JLYi2T z2e1E;Q#vvYYa4*nQq^*F0P`UX!`cZnwR9AWf>A-Cb!zUCsKWpN002ovPDHLk FV1jQ$eNX@Z literal 0 HcmV?d00001 diff --git a/demos/nightbloom/shot-orb.png b/demos/nightbloom/shot-orb.png new file mode 100644 index 0000000000000000000000000000000000000000..be2e956ccb20f260ee6cad233f3767810d359ce3 GIT binary patch literal 269 zcmV+o0rLKdP)gB1P$)5UU-MQ@WIqu@6gPye6foBiKc)|0l50QvO2@6-SP z{`~`Eki{_kDf}te@=z%++BgE_bC4x4%R%@{@oRVpfba>m-TxN|H~y!IBhVcHv-E)B zCKOC}2Y^C=ZVouBw-F`Y=^6r{6uwiS4<*i#9kD}ZHSN>D3bAesAH(of%|~EMr-@h5 zE<1vRB!VAufwcK9)0 T^6LBF00000NkvXXu0mjf{tt21 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index f1f9cb7..d3100a5 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "serve": "bun host-web/serve.ts", "golden": "bun test/golden.ts", "e2e": "bun test/e2e-ppsspp.ts", - "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts", + "test": "bun scripts/build.ts hero >/dev/null && bun test/contract.ts && bun test --conditions=browser test/tailwind.test.ts test/renderer.test.ts test/svg-bake.test.ts test/devtools.test.ts test/hot.test.ts test/clock.test.ts test/tiles.test.ts && bun scripts/build.ts cafe-main >/dev/null && bun test --conditions=browser test/sim.test.ts && bun scripts/build.ts zoomlab-main >/dev/null && bun test --conditions=browser test/deepzoom-sim.test.ts && bun scripts/build.ts nightbloom-main >/dev/null && bun test --conditions=browser test/nightbloom.sim.test.ts", "tape": "bun scripts/tape.ts", "tape:check": "bun scripts/tape.ts replay hero-main test/tapes/hero-main.tape.json --assert test/tapes/hero-main.hashes.json", "devtools": "bun scripts/devtools.ts", diff --git a/test/nightbloom.sim.test.ts b/test/nightbloom.sim.test.ts new file mode 100644 index 0000000..d4cb5a6 --- /dev/null +++ b/test/nightbloom.sim.test.ts @@ -0,0 +1,183 @@ +// test/nightbloom.sim.test.ts — NIGHTBLOOM under the deterministic sim host. +// +// tidelight proved the architecture on a branching STORY; this one proves it +// on a vertical DANMAKU SHOOTER: free 8-way movement, held-trigger autofire, +// form-switching, homing shots, graze, a midboss and a three-card final boss +// whose spirals come off a quantized sine table — all simulated in fixed +// 1/60 s micro-ticks (ticksPerFrame() per host frame). +// +// Two tapes: +// THE MARKSMAN — a full clear (~183 s): the catnip opens the night alone, +// its first ascension wakes the sakura, the midboss's fall +// rouses the gorilla, and the rotation walks all three +// through the pilot seat to break the diva's last card. +// THE SLEEPER — nobody home (~40 s): the lone catnip dies with no form +// awake to switch to, and the night ends on the spot. +// +// Claims, same as the cafe/tidelight suites but on gameplay: +// IDENTITY same tape -> byte-identical pixel trace +// CHAOS wall-clock sleeps + GC between frames change nothing +// SUBSAMPLING the 4 Hz and 2 Hz worlds are strict subsamples of 60 Hz — +// the low-rate player dodges the SAME spiral +// AUGURY phase omens ride the effect shell; the dusk omen lands at +// exactly 1.0 s at every rate (battle tick 1 runs inside the +// START press frame), and later phase boundaries quantize to +// the frame that contains their tick: (at + 1) - 1/hz +// THE NIGHT the runs actually happened: dawn with the exact score / +// graze / kill / bloom ledger, eternal night for the sleeper +// (tree probes), and the content tables are closed +// +// Tape discipline (cadence rules from the tidelight suite): +// - event times sit on the 0.5 s grid; same-button presses >= 1 s apart; +// - movement/fire ride hold events (level-triggered): a hold's mask goes +// true at the same battle tick at every rate. One-frame PULSES of held +// verbs are not rate-portable, so only holds steer the ship. + +import { describe, expect, test } from "bun:test"; +import { runScenario, treeHasText, type Trace } from "../host-sim/sim.ts"; +import { BTN } from "../spec/spec.ts"; +import { validateContent } from "../demos/nightbloom/data.ts"; + +const MARKSMAN_SECONDS = 190; // dawn settles at ~184.1 s +const SLEEPER_SECONDS = 60; // the lone catnip falls at ~40 s + +// THE MARKSMAN — sweep-dodge and rotate. The switch presses no-op while a +// form is still locked, then pick each newcomer up as the night wakes it; +// over the night every form flies. +const MARKSMAN = (() => { + const T: { at: number; press?: number; hold?: number }[] = [{ at: 1.0, press: BTN.START }]; + // fire is held from 1.5 s to the end; 1.5 s sweep legs dodge aimed streams + let dir: number = BTN.LEFT; + for (let t = 1.5; t < 186; t += 1.5) { + T.push({ at: t, hold: BTN.CROSS | dir }); + dir = dir === BTN.LEFT ? BTN.RIGHT : BTN.LEFT; + } + // dusk: the catnip hunts alone + T.push({ at: 21.0, press: BTN.TRIANGLE }); // NINE LIVES + T.push({ at: 40.0, press: BTN.TRIANGLE }); // NINE LIVES + // from midnight: 9 s cycles — lantern seat, STONEHEART (heal + shield), + // strike window under the shield, opportunistic spell, next form up + for (let t = 44; t <= 170; t += 9) { + T.push({ at: t, press: BTN.RTRIGGER }); + T.push({ at: t + 1.5, press: BTN.TRIANGLE }); + T.push({ at: t + 2.5, press: BTN.LTRIGGER }); + T.push({ at: t + 3.5, press: BTN.TRIANGLE }); + T.push({ at: t + 6, press: BTN.RTRIGGER }); + } + // petal clears for the diva's dense cards + for (const t of [139.0, 157.0]) { + T.push({ at: t, press: BTN.RTRIGGER }); + T.push({ at: t + 1.5, press: BTN.TRIANGLE }); // PETALFALL + T.push({ at: t + 3.0, press: BTN.LTRIGGER }); + } + return T.sort((a, b) => a.at - b.at); +})(); + +const SLEEPER = [{ at: 1.0, press: BTN.START }]; + +const marksman = (hz: number) => ({ app: "nightbloom-main", hz, seconds: MARKSMAN_SECONDS, script: MARKSMAN }); +const sleeper = (hz: number) => ({ app: "nightbloom-main", hz, seconds: SLEEPER_SECONDS, script: SLEEPER }); + +const m60: Trace = await runScenario(marksman(60)); +const m4: Trace = await runScenario(marksman(4)); +const m2: Trace = await runScenario(marksman(2)); +const s60: Trace = await runScenario(sleeper(60)); +const s2: Trace = await runScenario(sleeper(2)); + +describe("nightbloom: content data", () => { + test("the tables are closed: art, stats, waves, phases, spell cards", () => { + expect(validateContent()).toEqual([]); + }); +}); + +describe("nightbloom: determinism", () => { + test("same tape, same night: repeat runs are hash-identical", async () => { + const again = await runScenario(marksman(60)); + expect(again.hashes).toEqual(m60.hashes); + expect(again.effects).toEqual(m60.effects); + }, 60_000); + + test("chaos cannot reach the garden: sleeps + garbage + GC change nothing", async () => { + // 760 chaos frames sleep up to 6 ms each — give the wall clock room + const chaos = await runScenario(marksman(4), { maxSleepMs: 6, gcEvery: 32 }); + expect(chaos.hashes).toEqual(m4.hashes); + expect(chaos.effects).toEqual(m4.effects); + }, 60_000); + + test("the low-rate worlds are strict subsamples of the 60 Hz world", () => { + for (const t of [m4, m2]) { + const k = 60 / t.hz; + for (let m = 0; m < t.frames; m++) { + expect(t.hashes[m]).toBe(m60.hashes[k * (m + 1) - 1]); + } + } + }); + + test("the sleeper's night subsamples too — losing is just as deterministic", () => { + const k = 60 / s2.hz; + for (let m = 0; m < s2.frames; m++) { + expect(s2.hashes[m]).toBe(s60.hashes[k * (m + 1) - 1]); + } + }); + + test("the settled outcome screens are byte-equal across rates", () => { + expect(Buffer.from(m4.finalFrame).equals(Buffer.from(m60.finalFrame))).toBe(true); + expect(Buffer.from(m2.finalFrame).equals(Buffer.from(m60.finalFrame))).toBe(true); + expect(Buffer.from(s2.finalFrame).equals(Buffer.from(s60.finalFrame))).toBe(true); + }); +}); + +describe("nightbloom: the augury rides the effect shell", () => { + test("three omens, delivered exactly one virtual second after they are asked", () => { + for (const t of [m60, m4, m2]) { + const cmds = t.effects.filter((e) => e.t === "command" && e.kind === "augury"); + const dels = t.effects.filter((e) => e.t === "delivery" && e.kind === "augury"); + expect(cmds.length).toBe(3); + expect(dels.length).toBe(3); + for (let i = 0; i < 3; i++) { + expect(dels[i].frame - cmds[i].frame).toBe(t.hz); // 1.0 virtual second + } + } + }); + + test("the omens are emitted on the tick grid, quantized per rate as designed", () => { + // Dusk is asked on battle tick 1, which runs INSIDE the START press frame + // (the first batch ticks immediately), so its command lands at exactly + // 1.0 s at every rate. Midnight (tick 56*60) and witching (tick 104*60) + // run on the last tick of their batch, i.e. during the frame that ENDS at + // 57 s / 105 s — the command is logged at the frame's start: 57 - 1/hz + // and 105 - 1/hz. The world itself subsamples exactly (the hash tests + // above); this pins how tick-grid events quantize onto each host's grid. + for (const t of [m60, m4, m2]) { + const secs = t.effects.filter((e) => e.t === "command" && e.kind === "augury").map((e) => e.frame / t.hz); + expect(secs[0]).toBeCloseTo(1.0, 10); + expect(secs[1]).toBeCloseTo(57 - 1 / t.hz, 10); + expect(secs[2]).toBeCloseTo(105 - 1 / t.hz, 10); + } + }); +}); + +describe("nightbloom: the night actually happened", () => { + test("the marksman breaks the diva's last card, and the ledger is exact", () => { + for (const t of [m60, m2]) { + expect(treeHasText(t.tree, "DAWN BREAKS")).toBe(true); + expect(treeHasText(t.tree, "THE DIVA FALLS SILENT")).toBe(true); + expect(treeHasText(t.tree, "SCORE: 8575")).toBe(true); + expect(treeHasText(t.tree, "GRAZE: 92")).toBe(true); + expect(treeHasText(t.tree, "FOES FELLED: 33")).toBe(true); + // The whole roster woke and every form survived to see the sun. + expect(treeHasText(t.tree, "GREATEST BLOOM: STAGE 3")).toBe(true); + expect(treeHasText(t.tree, "SURVIVING FORMS: 3 OF 3 AWAKENED")).toBe(true); + } + }); + + test("the sleeper's lone catnip falls with nobody to switch to", () => { + for (const t of [s60, s2]) { + expect(treeHasText(t.tree, "ETERNAL NIGHT")).toBe(true); + expect(treeHasText(t.tree, "THE GARDEN FALLS DARK")).toBe(true); + expect(treeHasText(t.tree, "FOES FELLED: 0")).toBe(true); + expect(treeHasText(t.tree, "SURVIVING FORMS: 0 OF 1 AWAKENED")).toBe(true); + expect(treeHasText(t.tree, "GREATEST BLOOM: STAGE 1")).toBe(true); + } + }); +}); From 28c3f69e70439be72bced288e1f1d34df42ac851 Mon Sep 17 00:00:00 2001 From: lavs <79836722+SummonLav@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:33:52 +0800 Subject: [PATCH 2/5] fix(nightbloom): scrolling left panel + dawn medals (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(nightbloom): left-panel long lines scroll like a ticker instead of clipping The AUGURY omen, the boss's name and the spell-card title overflowed the 134 px left panel and clipped at the box edge. They now ride a marquee: two copies loop leftward, driven by the battle tick — so the scroll subsamples exactly like everything else — and lines short enough to fit hold still. Width is estimated from the glyph count. The hero screenshot is retaken on the FINALE with the moon cat piloting (NINE LIVES charging) and the panels mid-scroll. Co-Authored-By: Claude Fable 5 * feat(nightbloom): dawn medals — decorations for the wrong things Clear the night and the dawn screen pins HONORS, OF A SORT on you: up to three roast medals derived from the run's ledger — MERCY MEDAL (foes that strolled off unharmed), PINCUSHION (hits taken, and proud), COMPOST AWARD (gardeners wilted on your watch), OUTSTAYED WELCOME (cards that died of old age), LITTERBUG (motes left in the grass), PERSONAL SPACE (not one graze all night). A spotless run earns SUSPICIOUSLY PERFECT — the night demands a rematch. Win screen only; the eternal night roasts nobody. The engine grows a roast ledger (escaped / hitsTaken / motesMissed / cardTimeouts counters plus seam accessors), the end screen is compacted to fit the medal case, and the sim test pins the marksman's exact decorations: 2 strolled off, struck 33 times, 24 motes in the grass. Co-Authored-By: Claude Fable 5 * feat(nightbloom): the dawn medal ceremony — score first, then the stamp The dawn screen is a two-act sequence now, on the outcome screens' own clock: - Act one: the score takes center stage and ROLLS UP arcade-style over the first 1.2 s; the rest of the ledger waits in small print below. - Act two, at the 2 s mark: ONE medal — the roast pick of the run — is slapped onto the glass: a PixelLab gold-and-ribbon medal base slams in from 3x scale, tilted, lands at -12 degrees with a screen shake and a thud from the sound sink, and the paperwork dims beneath it. The title is stamped in layered art lettering (amber over pink over shadow) with the detail line on a plaque below. The end clock is (wall ticks - the tick the outcome settled on): both terms are rate-aligned, and the settling frame updates it in-batch, so the count-up and the slam land on the same virtual moments at every simulationHz — the full 190 s runs, stamp animation included, remain strict 4/2 Hz subsamples of 60 Hz. The sim test pins the final frame: score 8575 on stage, MERCY MEDAL stamped over it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- assets/screenshots/nightbloom.png | Bin 20952 -> 20455 bytes demos/nightbloom/README.md | 6 +- demos/nightbloom/app.tsx | 117 +++++++++++++++++++++++++----- demos/nightbloom/data.ts | 9 +++ demos/nightbloom/engine.ts | 52 ++++++++++++- demos/nightbloom/medal.png | Bin 0 -> 5205 bytes demos/nightbloom/sfx.ts | 5 ++ test/nightbloom.sim.test.ts | 6 +- 8 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 demos/nightbloom/medal.png diff --git a/assets/screenshots/nightbloom.png b/assets/screenshots/nightbloom.png index fc82c6d7f675078b1eb0b93b561cbd72badfc222..b41778a6bfb7c132e28c5dbf66ebbb14715242e1 100644 GIT binary patch literal 20455 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 H>T~n|p^HMs diff --git a/demos/nightbloom/README.md b/demos/nightbloom/README.md index 583349a..0e438a1 100644 --- a/demos/nightbloom/README.md +++ b/demos/nightbloom/README.md @@ -14,9 +14,9 @@ left, the roster on the right). ![NIGHTBLOOM: the NIGHT SPARROW DIVA's first card](../../assets/screenshots/nightbloom.png) -*The witching hour. The NIGHT SPARROW DIVA opens NIGHT SONG — WANDERING -CHORUS; MOON PRIMROSE — a mountain of abs with the sweetest face — holds -the pilot seat while the moon cat and the sakura rest.* +*The FINALE. The diva's phoenix form sings THE ETERNAL NIGHT; the moon +cat holds the pilot seat, NINE LIVES charging, while the long spell-card +names scroll through the left panel like a ticker.* ## Play it diff --git a/demos/nightbloom/app.tsx b/demos/nightbloom/app.tsx index 0919564..a797f8a 100644 --- a/demos/nightbloom/app.tsx +++ b/demos/nightbloom/app.tsx @@ -39,7 +39,7 @@ import { SHOTS, WAVES, } from "./data.ts"; -import { createNightbloom, FX_LIFE, type FloatFx, type Nightbloom, type PlantState } from "./engine.ts"; +import { createNightbloom, FX_LIFE, STAMP_AT, STAMP_IMPACT, type FloatFx, type Nightbloom, type PlantState } from "./engine.ts"; // --------------------------------------------------------------------------- // Title / endings @@ -70,32 +70,92 @@ function TitleScreen() { ); } +/** Chunky gold "art lettering": the same line three times, offset like a + * stamped foil title. */ +function ArtTitle(props: { text: string; y: number }) { + return ( + + + {props.text} + + + {props.text} + + + {props.text} + + + ); +} + function EndScreen(props: { game: Nightbloom; win: boolean }) { const g = props.game; // Only forms that ever WOKE count — a locked card never fought. const awakened = () => g.roster.filter((r) => r.unlocked()); const survivors = () => awakened().filter((r) => r.hp() > 0).length; + /** The dawn decoration: ONE medal that congratulates you for the wrong + * thing, picked in roast order; a spotless run earns suspicion. */ + const medal = (): { title: string; detail: string } => { + const wilted = awakened().length - survivors(); + if (g.escaped() > 0) return { title: "MERCY MEDAL", detail: g.escaped() + " FOES STROLLED OFF UNHARMED" }; + if (g.hitsTaken() > 0) return { title: "PINCUSHION", detail: "STRUCK " + g.hitsTaken() + " TIMES AND PROUD" }; + if (wilted > 0) return { title: "COMPOST AWARD", detail: wilted + " GARDENERS WILTED ON YOUR WATCH" }; + if (g.cardTimeouts() > 0) return { title: "OUTSTAYED WELCOME", detail: g.cardTimeouts() + " CARDS DIED OF OLD AGE" }; + if (g.motesMissed() > 0) return { title: "LITTERBUG", detail: g.motesMissed() + " MOTES LEFT IN THE GRASS" }; + if (g.graze() === 0) return { title: "PERSONAL SPACE", detail: "NOT ONE GRAZE ALL NIGHT" }; + return { title: "SUSPICIOUSLY PERFECT", detail: "THE NIGHT DEMANDS A REMATCH" }; + }; + /** The arcade count-up: the score rolls in over the first 1.2 s. */ + const shownScore = () => Math.floor(g.score() * Math.min(1, g.endTick() / 72)); + /** Stamp physics: appear huge and tilted, slam to rest, shake the room. */ + const slam = () => Math.min(1, Math.max(0, (g.endTick() - STAMP_AT) / (STAMP_IMPACT - STAMP_AT))); + const stamped = () => props.win && g.endTick() >= STAMP_AT; + const SHAKE = [3, -2, 2, -2, 1, -1, 0]; + const shake = () => { + const n = g.endTick() - STAMP_IMPACT; + return n >= 0 && n < 14 ? SHAKE[Math.min(6, n >> 1)] : 0; + }; return ( - + - + {props.win ? "THE DIVA FALLS SILENT" : "THE GARDEN FALLS DARK"} - + {props.win ? "DAWN BREAKS" : "ETERNAL NIGHT"} - - - - {"SCORE: " + g.score()} - {"GRAZE: " + g.graze()} - {"FOES FELLED: " + g.kills()} - {"GREATEST BLOOM: STAGE " + g.bestStage()} - {"SURVIVING FORMS: " + survivors() + " OF " + awakened().length + " AWAKENED"} - + {/* act one: the score takes the stage, rolling up arcade-style */} + + SCORE + {String(props.win ? shownScore() : g.score())} + + + {"FOES FELLED: " + g.kills() + " GRAZE: " + g.graze()} + {"GREATEST BLOOM: STAGE " + g.bestStage() + " SURVIVING FORMS: " + survivors() + " OF " + awakened().length + " AWAKENED"} - + {/* act two: the medal is slapped onto the glass */} + + + + + + + {medal().detail} + + + + + START RETURN TO TITLE @@ -371,6 +431,29 @@ function Field(props: { game: Nightbloom }) { // Side panels // --------------------------------------------------------------------------- +/** Long lines in the narrow panels scroll like a ticker instead of + * clipping: two copies loop leftward, driven by the battle tick (so the + * marquee subsamples exactly like everything else). Short lines hold + * still. Width is estimated from the glyph count. */ +function Marquee(props: { game: Nightbloom; text: string; cls: string; width: number }) { + const textW = () => props.text.length * 7; + const scroll = () => { + if (textW() <= props.width) return 0; + const span = textW() + 24; + return -((props.game.fxTick() * 0.6) % span); + }; + return ( + + + {props.text} + props.width}> + {props.text} + + + + ); +} + function LeftPanel(props: { game: Nightbloom }) { const g = props.game; const phaseName = () => { @@ -394,14 +477,14 @@ function LeftPanel(props: { game: Nightbloom }) { AUGURY - {g.augury()} + {(b) => ( - {b.def.name} - {b.def.phases[b.phase()].card} + + {"TIMEOUT " + g.bossCardSeconds() + "s"} )} diff --git a/demos/nightbloom/data.ts b/demos/nightbloom/data.ts index ce57bdd..fa760f9 100644 --- a/demos/nightbloom/data.ts +++ b/demos/nightbloom/data.ts @@ -400,6 +400,7 @@ export type SfxKind = | "shoot" | "unlock" | "heal" + | "stamp" | "boss-bird" | "boss-umbrella" | "hit" @@ -537,6 +538,14 @@ export const ART: ArtEntry[] = [ { name: "shot-mochi.png", prompt: `small round white mochi rice cake, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3003, transparent: true }, { name: "mote.png", prompt: `small silver-blue moonlight droplet, sparkling, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3004, transparent: true }, { name: "shot-banana.png", prompt: `curved ripe yellow banana, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3005, transparent: true }, + { + name: "medal.png", + prompt: + "ornate round golden medal with a laurel wreath rim and two red ribbon tails, " + + "smooth empty center plaque, shiny polished gold, cute pixel art game award, " + + "clean thick outline, single centered object", + w: 128, h: 128, seed: 5001, transparent: true, + }, // --- boss transformation portraits (64x64, chained from the mob art) ------ { name: "boss-kasa.png", diff --git a/demos/nightbloom/engine.ts b/demos/nightbloom/engine.ts index c16bbb6..5525fc2 100644 --- a/demos/nightbloom/engine.ts +++ b/demos/nightbloom/engine.ts @@ -236,6 +236,11 @@ export interface Nightbloom { graze: Accessor; kills: Accessor; bestStage: Accessor; + /** The roast ledger: what the dawn medals tease you about. */ + escaped: Accessor; + hitsTaken: Accessor; + motesMissed: Accessor; + cardTimeouts: Accessor; px: Accessor; py: Accessor; focus: Accessor; @@ -256,6 +261,8 @@ export interface Nightbloom { bossCardSeconds: Accessor; /** Battle tick of the last boss entry/metamorphosis, for the flash ring. */ bossFlash: Accessor; + /** Ticks since the outcome settled — the end screens' own clock. */ + endTick: Accessor; enemyShots: Accessor; playerShots: Accessor; motes: Accessor; @@ -283,6 +290,10 @@ const MAX_MOTES = 24; const SWITCH_TICKS = Math.round(SWITCH_COOLDOWN * TPS); const HURT_TICKS = Math.round(HURT_INVULN * TPS); +/** Dawn sequence beats (in end-screen ticks): the score gets the stage + * first, then the medal slams on and lands. */ +export const STAMP_AT = 120; +export const STAMP_IMPACT = 132; export function createNightbloom(): Nightbloom { const outcome = cell("title"); @@ -312,6 +323,7 @@ export function createNightbloom(): Nightbloom { const toasts = cell([]); const fxTick = cell(0); const bossFlash = cell(-1); + const endTick = cell(0); const roster: PlantState[] = PLANT_ORDER.map((kind, i) => ({ kind, @@ -336,8 +348,13 @@ export function createNightbloom(): Nightbloom { let invulnTicks = 0; let midbossDone = false; let bossDone = false; + let wallTicks = 0; let wiltTicks = 0; let rescues = 0; + const escaped = cell(0); + const hitsTaken = cell(0); + const motesMissed = cell(0); + const cardTimeouts = cell(0); const wilting = cell(false); const wiltSeconds = cell(0); const lastDx = cell(0); @@ -394,6 +411,7 @@ export function createNightbloom(): Nightbloom { invulnTicks = 0; midbossDone = false; bossDone = false; + wallTicks = 0; phase.set("dusk"); augury.set(""); second.set(0); @@ -418,8 +436,13 @@ export function createNightbloom(): Nightbloom { toasts.set([]); fxTick.set(0); bossFlash.set(-1); + endTick.set(0); wiltTicks = 0; rescues = 0; + escaped.set(0); + hitsTaken.set(0); + motesMissed.set(0); + cardTimeouts.set(0); wilting.set(false); wiltSeconds.set(0); lastDx.set(0); @@ -582,6 +605,7 @@ export function createNightbloom(): Nightbloom { toast(`SPELL CARD BROKEN: ${b.def.phases[idx].card}`); } else { toast(`THE CARD TIMES OUT: ${b.def.phases[idx].card}`); + cardTimeouts.set(cardTimeouts() + 1); } dropMotes(b.x(), b.y(), BOSS_PHASE_BOUNTY); enemyShots.set([]); // the break clears the sky @@ -617,6 +641,7 @@ export function createNightbloom(): Nightbloom { const def = PLANTS[p.kind]; const eff = Math.max(1, dmg - def.armor[p.stage() - 1]); p.hp.set(p.hp() - eff); + hitsTaken.set(hitsTaken() + 1); invulnTicks = HURT_TICKS; fx(px(), py() - 12, `-${eff}`, "hurt"); sfx("hurt"); @@ -845,6 +870,7 @@ export function createNightbloom(): Nightbloom { } if (f.y() > FIELD.y0 + FIELD.h + 18) { foes.set(foes().filter((x) => x.id !== f.id)); // it drifts past the garden + escaped.set(escaped() + 1); continue; } // fire @@ -1155,6 +1181,7 @@ export function createNightbloom(): Nightbloom { } if (m.y() > FIELD.y0 + FIELD.h + 10) { motes.set(motes().filter((x) => x.id !== m.id)); + motesMissed.set(motesMissed() + 1); continue; } const dx = m.x() - px(); @@ -1203,6 +1230,13 @@ export function createNightbloom(): Nightbloom { start(); started = true; // fall through: the first batch ticks this same frame } else if (o === "dawn" || o === "eternal") { + // The outcome screens keep their own clock as (wall ticks - the tick + // the outcome settled on). Both terms are rate-aligned, so the medal + // stamp lands at the same virtual moment at every simulationHz. + wallTicks += ticksPerFrame(); + const prev = endTick(); + endTick.set(Math.max(0, wallTicks - tick)); + if (o === "dawn" && prev < STAMP_IMPACT && endTick() >= STAMP_IMPACT) sfx("stamp"); if (pressed & BTN.START) toTitle(); return; } @@ -1212,10 +1246,17 @@ export function createNightbloom(): Nightbloom { if (codexPage() > 0) return; const k = ticksPerFrame(); + wallTicks += k; for (let i = 0; i < k; i++) { - if (outcome() !== "battle") return; + if (outcome() !== "battle") break; stepTick(i === 0 ? pressed : 0, buttons); } + // If the night ended inside this batch, the end clock starts NOW: the + // wall keeps moving through the frame the outcome settled in, so the + // stamp timeline is subsample-exact at every rate. + if (outcome() === "dawn" || outcome() === "eternal") { + endTick.set(Math.max(0, wallTicks - tick)); + } } // The lab seam (same spirit as __tidelight): read-only accessors so the sim @@ -1236,6 +1277,10 @@ export function createNightbloom(): Nightbloom { unlockedCount: () => roster.filter((r) => r.unlocked()).length, wilting: () => wilting(), rescues: () => rescues, + escaped: () => escaped(), + hitsTaken: () => hitsTaken(), + motesMissed: () => motesMissed(), + cardTimeouts: () => cardTimeouts(), rosterGlow: () => roster.map((r) => ({ kind: r.kind, stage: r.stage(), hp: r.hp(), glow: Math.round(r.glow()) })), foesAlive: () => foes().length, bulletCount: () => enemyShots().length, @@ -1258,6 +1303,10 @@ export function createNightbloom(): Nightbloom { graze, kills, bestStage, + escaped, + hitsTaken, + motesMissed, + cardTimeouts, px, py, focus, @@ -1274,6 +1323,7 @@ export function createNightbloom(): Nightbloom { bossCard, bossCardSeconds, bossFlash, + endTick, enemyShots, playerShots, motes, diff --git a/demos/nightbloom/medal.png b/demos/nightbloom/medal.png new file mode 100644 index 0000000000000000000000000000000000000000..b772c304740a55f4b29b06806984f908bf67f16f GIT binary patch literal 5205 zcmY+I_dgVV;K$#0&K^hh%0*O0NhR|f%7tv%dlVt#jO@dwjAW0Gxa^FQnGw#;IGvF_ zvNO&I8E54C`Tq9({_uLf{)Ok`8Lg|0W~Aex0{}3pKYF15-(&xGG*thw18GYDfQ?W6 zfwJMttSw8e;;Y8&+qP0la#!^3F!gFKisXn1T8sOO21uHGAB!@8Z?>-awWeFtNtxxecgC(wzPa>qy*#s#K(Aa zE5Lx7>D;PiV>@twO!Olv9vQVvx+bZMWH|g^_@B|m|ADmcv zUr_jH6?s12e||Qc9LWlR(lz93tc%dcIe87Y;NeL5yi zUf_JzOC%aqRm-GWG&E&cGz5(d{_*#lCEE9PSpU1y8pZ)8HI`SkAG0WPMVD05Y2oVE z`4}xX6*3eKMiqOBg@MVquHK}4)fkst0H34InI~yxH_D_+c6HVgK64K55_H@Pw28Py zjKi-c`lIE6p$z1AN$mIGQ`^Ta^`X+YRmP!_N~iPvD@#fWmEDjWkH@m_3NaWB z^j&&i8VCp_v@NaSqfYh$jkzg8yC;DDTJ>bT{j__E*GZ*|=BNy&A>V<$q@+41F-wl- zp0elpQ^d)eHEnByv&(oSZ^xno(#Z1K;~RNl6>CZCvImXX)5$9i8H95dOJGwC<5+)u zdL-2~9-#)rC|dKgaom^*4VujfUn|YtH*&@$E@)S(zebMOQr7gL4VSHfN%44LG1T{W zS2H^duU!vQj?oDGarY%l*)s+Yg{Kb!CNnrgG5O|9cK+zm7K1tRt`7v&GoVoVtQ(o*N2wb+Lkz4&HH2JJsoBO*(ArCzv-XxG z!tM%jLeF?Te?n->{2OoIuG~)D6OOns{Uq@)ZM!zu=&ZV%m>=HrvG_cb+j5adDFb4E zz9qZ<3Kv<@!T8~)iM)KTtx}40VtSlg)8gxR7^2pimo|Z}&x?gb+4>a!E&VNS zcJYi4wkqBK6?;#)!Fayz2X6~<)5L!3lw$9XrvK5WsjWa#iTPenT`XG%*^;6)Z2lm# zX@6-W#|G+>?)gJDbW4Y0rR;HCn*s8ki=IW)p{3C3jM`4zcE{`hSQ2eazJ+;VFPmjK6zs3YFM}vMk!6X52?Q>rh~4n{MkjufHrb(rQhG$F$d6{>}dVp>h;^hnd@zqM`upfUz$ty@TYm zD;5hf!xnRFG&9B(G3|HR4f)IC3(CJMC*iu}*ZJjSyH4YDI0MNrtkvofI~g-n!9BC^ z4Kw32vf0gw-t$ne(5#nX7l6G;hs*w5j8+4!Ro9*_qoV_?1(S3- z!5tos5$o}d*+0JI-$B-|W^Eeqwv0KW;}YJ1{7*lcOv?I-0uaP69?U_^`-@Bq#Ej+( zFwms!yI`>m<_9QpL5k02^I$PR5%+v5hb892Pi7ws1YI}wuHzdH%sFbqgmgreuaQZ! zd$*{SpJwJE6wE3MnPe>L(m(>qm>yV`9pyhbzT|C9fc8mK(jS|5>dw68Up!dti8KvUnM(?km zAX38gkC+RbTL(%9ovaOn?u*o$D&sEv$}NZuH}6_B76!|u-c#;qQlTY#Vn`Csq+#CU#Z;Y8=cfE(E~Z8sG|MUC1G%t0)XRL z_QA_QhIt+t{^r_iN660~>2TGk-EP|2P8nw{hjEsIQ(C~Ih)*RD zJjN{vux+(bmPAhCctjGSCp{E8 zU_N|}^IEmn?t^y#)Ez8W7C}?=0wbl%fi>dcQrK&x>PaF-{wUM{s3Au0S~hVTStU6@ z0~I)Tq2d}D3SK_rVA~np65NxZ!tVW~?X5+gcKnI}^Qk+>$`#UpX5}m@MIa_b01WDs zR3Bvvykn%sw#*1zW`H2B4fBF`GPPncJFUCORf3>o54fxtJm*?p4RO*NtHe?QZ#Z@D z3ZAXvTcQrAzm@1j3pmfasb<(an?tow^M^IUU=Xzt0XZ3A5=6p41FQzLqV^`akXR)u zCGLOVFq)arr5bv387|G+stEDi&B$2|b4NSVsvI(^b`GHZ#Uwi7X;F*SbgS#Fl zz$)g}+-3`XMtG?UKuH!KwFq_8Y8w*|*f~3Rv~TBey>y2b+R9I{NRL9)rk76IAiulIcWy9-KW>%7 zhIS0_fX`f=xm06NRox(XoR!F`3-H(~U(W`E5D^z~Q6A-pBgl+&VVDPkT{OGnf#en0 z-8L|2UtnBgiytST>fVUHN4H9z&;kH^UPhHSbNMw~YsY!oT)Dt#`F1Zf=%&)x$g|}m zzFn<1ip`{M^aQA_$g`e)0u7JRp3k8~Q2G^|9TKXCv42p7i%~Mhu)C;`Qpe&)I}m1{ z`SOnthd-*eLVk@nTLaCZ_-H7g*``zZ>n!EDeCw{5**H`#@_xe$Y_VV6mpX4e6~MFv zU6u|n3rf6|9nW#j#umYag~Ocm*UV5=tqSI%X=$%4*#Z>rpfV4dbsQJapjEnRuZYoy zc>(NPMyJZkZclh~7nqLq&jAio|$XH7tui-G$uAe!mYK4PZ$L;v7ofrU$Ats}J(I))j3fD4K zVd1?n3YYP?Z1+f9xezXH+|3#m_wmf0GbAnHT@B8gtXm&L z2v$aP*kNMjxm%1p{u5Qd76bjzzH5ek8H4D0&a>{Lg&;NjIQe<=E$6unvOMUtI2xw~ zt&Z~+_atR*H0z*5euuRdMqqfGWtwgknI|gEF`@BPVJl~CIv2ISDwa7j2<64~LXIB83+!gmK$)ews2fb(zMiFZ+v}mo*D#+F(K57QdRqM zGIK>F!6z)vO5y7cAM*fV^(5vu^NM+wRSy}jB~Vh~T;mRN5hE*7FfzRUS=R;Qq1kuS z<16LBB=>A-+`|z(Reb`}<03aam%M$F2O=c$Lj-KhAzJq!VU9p5%z=XJm-X`U7ZJO{G5cBA8zj>*(|w}h1KUl(XO zw27M%A*LfhyB?$i;Q+fXX74gOPl0kOa(nVcG(Z4IhG5NoRk(Le=rsExF{;vntU0wY zTh9y%HDza%3vj-02VSqU&)XMO0u>t05D0>jW7{@B@27@_*k24GbXna|JWe@ zgQ;DUrH&U-zZ_j(kGCHVWyMEz&8w>uu)UMhqJd1S6wSuUGz(9xv?9C+8O{`4Cj2JiHm$i$ z)bYF~x@SM=uVbT5R5<*2>i1Ymh%w8KTj*buE=(IiL5RUsOo*jkM2pZ~CWq2H+U1=! z`4l1_->qz=guo3g`RF7?4Mg(Jt&zN!CEiW$_>ToTTNq@>2d{8sDwWJpiB9eN z;!ljl9>}|Qt^9?AQ;J-BXv`)H5&_-p>sLMv56Le(|DAq-!OePOcX^sPrcU>mU(x#= z=b&nJTAG!fkni}I)+i;Hapg#Q;PxdFW9LWHsBZ&k>_fHSY5%s*Q`%+{@|Yh{Q!chW zQ7-vwOdE5`7as>N*QKT7B3C@f_5FcfR`|SH?S=W9n~k+Nl^a}ZRREi-i@nKu9!$qy z$p9`7WC;W^MbL^gx*pJj)``kh8`^J_9<{yatRO9?cVY=n>pWq$M`rs}xxK$(C~2C5 zXQcVYChTSaF}_>btyqlEcn7tKWf@MP9DK+>JtLR+0dP@lnYRbzs8_qh?wgz1WXXY6 zvVClbMzW!#5w={sCvafoPrGfC-X|9d{W{V5=MYJPVCQrC27N!zbH5o9xgo5y0}9DW<;#UwdYF1{d)~1 ze?jY5hrn@Ppl_(^KXv^qn~lNvFYT$IR?1Ivi1QN1KcIC~a?Wfa>~&lGy@zbI?U0iR z`{NLxY?BpDpTjj?~ih0|I39y{h{`Q J&nh2Gm literal 0 HcmV?d00001 diff --git a/demos/nightbloom/sfx.ts b/demos/nightbloom/sfx.ts index e847a80..f90fb1b 100644 --- a/demos/nightbloom/sfx.ts +++ b/demos/nightbloom/sfx.ts @@ -74,6 +74,11 @@ const SFX: Record = { evolve: { voices: ARP([523.25, 659.25, 783.99], 0.07, 0.12, 0.12, "sine") }, unlock: { voices: ARP([659.25, 880, 1174.66, 1567.98], 0.09, 0.2, 0.13, "triangle") }, heal: { voices: [{ f0: 880, f1: 1320, dur: 0.07, type: "sine", gain: 0.05 }], throttle: 260 }, + // the medal slamming onto the glass + stamp: { + voices: [{ f0: 150, f1: 40, dur: 0.28, type: "sawtooth", gain: 0.3 }], + noise: [{ dur: 0.16, gain: 0.24, cutoff: 900 }], + }, // the diva's cry — three quick chirps, up, down-up, and away "boss-bird": { voices: [ diff --git a/test/nightbloom.sim.test.ts b/test/nightbloom.sim.test.ts index d4cb5a6..1b36cb1 100644 --- a/test/nightbloom.sim.test.ts +++ b/test/nightbloom.sim.test.ts @@ -162,12 +162,16 @@ describe("nightbloom: the night actually happened", () => { for (const t of [m60, m2]) { expect(treeHasText(t.tree, "DAWN BREAKS")).toBe(true); expect(treeHasText(t.tree, "THE DIVA FALLS SILENT")).toBe(true); - expect(treeHasText(t.tree, "SCORE: 8575")).toBe(true); + // Act one: the score takes the stage (settled by the final frame). + expect(treeHasText(t.tree, "8575")).toBe(true); expect(treeHasText(t.tree, "GRAZE: 92")).toBe(true); expect(treeHasText(t.tree, "FOES FELLED: 33")).toBe(true); // The whole roster woke and every form survived to see the sun. expect(treeHasText(t.tree, "GREATEST BLOOM: STAGE 3")).toBe(true); expect(treeHasText(t.tree, "SURVIVING FORMS: 3 OF 3 AWAKENED")).toBe(true); + // Act two: ONE medal, stamped on and congratulating the wrong thing. + expect(treeHasText(t.tree, "MERCY MEDAL")).toBe(true); + expect(treeHasText(t.tree, "2 FOES STROLLED OFF UNHARMED")).toBe(true); } }); From b858ea50265acb32760cd190be2e827b399f9f59 Mon Sep 17 00:00:00 2001 From: lavs <79836722+SummonLav@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:31:42 +0800 Subject: [PATCH 3/5] perf(nightbloom): optimize PSP swarm rendering Batch PSP swarm rendering, fix the 59-second particle corruption, reduce second-phase combat load, and rebalance healing around Moon Primrose. --- core/src/draw.rs | 48 +++- core/src/lib.rs | 111 +++++++++ core/src/tests.rs | 39 ++- demos/nightbloom/README.md | 15 +- demos/nightbloom/app.tsx | 458 ++++++++++++++++++++++++++++------- demos/nightbloom/data.ts | 61 ++--- demos/nightbloom/engine.ts | 461 ++++++++++++++++++++++++------------ native/src/ffi.rs | 43 ++++ scripts/bench-ppsspp.ts | 14 ++ src/host.ts | 7 + src/hot.ts | 88 ++++++- test/hot.test.ts | 51 +++- test/nightbloom.sim.test.ts | 22 +- 13 files changed, 1103 insertions(+), 315 deletions(-) diff --git a/core/src/draw.rs b/core/src/draw.rs index b1c4558..511e6d4 100644 --- a/core/src/draw.rs +++ b/core/src/draw.rs @@ -519,13 +519,14 @@ struct Walker<'a> { inspect_slot: u32, /// World AABB of `inspect_slot`, set when the walk reaches it. inspect_hit: Option, + particle_layers: &'a [crate::ParticleLayer], } /// Build the full DrawList for the current (laid-out) tree. `frame` is the /// core's vblank counter (Ui.frame); animated sprites pick their cell from it. /// `screen` is the viewport every coordinate is clipped to. #[allow(clippy::too_many_arguments)] -pub fn build( +pub(crate) fn build( tree: &Tree, styles: &StyleTable, fonts: &Fonts, @@ -534,6 +535,7 @@ pub fn build( textures: &mut Vec, tex_free: &mut Vec, discs: &mut DiscCache, + particle_layers: &[crate::ParticleLayer], dl: &mut DrawList, inspect_id: i32, inspect_prev: Option<(f32, f32, f32, f32)>, @@ -559,6 +561,7 @@ pub fn build( discs, inspect_slot, inspect_hit: None, + particle_layers, }; let root_slot = crate::tree::split_id(spec::ROOT_ID).1; w.paint(root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), dl); @@ -734,6 +737,49 @@ impl<'a> Walker<'a> { self.emit_tex_quad(dl, &world, l.w, l.h, node.tex as u32, op, &clip, fu0, fv0, fu1, fv1); } + // -- paint-only particle layer --------------------------------------- + if let Some(layer) = self.particle_layers.iter().find(|layer| layer.node == node.id(slot)) { + for particle in &layer.particles { + if !particle.x.is_finite() + || !particle.y.is_finite() + || !particle.size.is_finite() + || particle.size <= 0.0 + || particle.size > 64.0 + || alpha(particle.color) == 0 + { + continue; + } + let r = roundf(particle.size * 0.5).max(1.0) as u32; + if let Some((tex, dim)) = disc_texture(self.discs, self.textures, self.tex_free, r) { + let (x, y) = world.apply(particle.x, particle.y); + let size = (r * 2) as f32; + self.emit_corner_quad( + dl, + tex, + x, + y, + size, + 0.0, + 0.0, + size / dim as f32, + scale_alpha(particle.color, op), + &clip, + ); + } else { + self.emit_box( + dl, + &world, + particle.x, + particle.y, + particle.x + particle.size, + particle.y + particle.size, + Fill::Flat(scale_alpha(particle.color, op)), + &clip, + ); + } + } + } + // -- children (overflow-hidden scissor around them; z-index stable // sort within siblings) --------------------------------------------- let mut child_clip = clip; diff --git a/core/src/lib.rs b/core/src/lib.rs index 52fcfaf..7bc932b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -203,6 +203,30 @@ struct TimelineInst { loop_frames: u16, } +/// One paint-only particle owned by a particle-layer node. Coordinates are +/// local to that node; the normal tree transform and overflow clip apply. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Particle { + pub x: f32, + pub y: f32, + pub size: f32, + pub color: u32, +} + +pub(crate) struct ParticleLayer { + pub node: i32, + pub particles: Vec, +} + +#[inline] +fn valid_particle(particle: &Particle) -> bool { + particle.x.is_finite() + && particle.y.is_finite() + && particle.size.is_finite() + && particle.size > 0.0 + && particle.size <= 64.0 +} + /// The retained UI core. One per host/screen. pub struct Ui { tree: tree::Tree, @@ -210,6 +234,7 @@ pub struct Ui { fonts: text::Fonts, anims: anim::Anims, timelines: Vec, + particle_layers: Vec, layout: layout::LayoutEngine, /// Generation-tagged texture slots (handles per spec.ts TEX_SLOT_BITS). textures: Vec, @@ -249,6 +274,7 @@ impl Ui { fonts: text::Fonts::new(), anims: anim::Anims::new(), timelines: Vec::new(), + particle_layers: Vec::new(), layout: layout::LayoutEngine::new(), textures: Vec::new(), tex_free: Vec::new(), @@ -290,6 +316,7 @@ impl Ui { for slot in slots { let nid = self.tree.slots[slot as usize].id(slot); self.anims.kill_node(nid); + self.particle_layers.retain(|layer| layer.node != nid); self.tree.free_slot(slot); } self.layout.dirty = true; @@ -355,6 +382,89 @@ impl Ui { } } + /// Replace the paint-only particle batch attached to `id`. Capacity is + /// retained across frames, so steady-state updates allocate nothing. + pub fn set_particles(&mut self, id: i32, particles: &[Particle]) { + if self.tree.resolve(id).is_none() { + return; + } + if let Some(layer) = self.particle_layers.iter_mut().find(|layer| layer.node == id) { + layer.particles.clear(); + layer.particles.extend(particles.iter().copied().filter(valid_particle)); + return; + } + self.particle_layers.push(ParticleLayer { + node: id, + particles: particles.iter().copied().filter(valid_particle).collect(), + }); + } + + /// Packed host form: repeated [x:f32 bits, y:f32 bits, size:f32 bits, + /// color:ABGR] words. Only `count` complete particles are consumed. + pub fn set_particle_words(&mut self, id: i32, words: &[u32], count: usize) { + if self.tree.resolve(id).is_none() { + return; + } + let index = match self.particle_layers.iter().position(|layer| layer.node == id) { + Some(index) => index, + None => { + self.particle_layers.push(ParticleLayer { node: id, particles: Vec::new() }); + self.particle_layers.len() - 1 + } + }; + let particles = &mut self.particle_layers[index].particles; + particles.clear(); + let count = count.min(words.len() / 4); + if particles.capacity() < count { + particles.reserve(count - particles.capacity()); + } + for chunk in words[..count * 4].chunks_exact(4) { + let particle = Particle { + x: f32::from_bits(chunk[0]), + y: f32::from_bits(chunk[1]), + size: f32::from_bits(chunk[2]), + color: chunk[3], + }; + if valid_particle(&particle) { + particles.push(particle); + } + } + } + + /// Byte-oriented host form. Unlike casting an ArrayBuffer pointer to + /// `u32`, this remains defined even when a JS engine returns an unaligned + /// typed-array backing store. + pub fn set_particle_bytes(&mut self, id: i32, bytes: &[u8], count: usize) { + if self.tree.resolve(id).is_none() { + return; + } + let index = match self.particle_layers.iter().position(|layer| layer.node == id) { + Some(index) => index, + None => { + self.particle_layers.push(ParticleLayer { node: id, particles: Vec::new() }); + self.particle_layers.len() - 1 + } + }; + let particles = &mut self.particle_layers[index].particles; + particles.clear(); + let count = count.min(bytes.len() / 16); + if particles.capacity() < count { + particles.reserve(count - particles.capacity()); + } + for chunk in bytes[..count * 16].chunks_exact(16) { + let word = |at: usize| u32::from_le_bytes([chunk[at], chunk[at + 1], chunk[at + 2], chunk[at + 3]]); + let particle = Particle { + x: f32::from_bits(word(0)), + y: f32::from_bits(word(4)), + size: f32::from_bits(word(8)), + color: word(12), + }; + if valid_particle(&particle) { + particles.push(particle); + } + } + } + // ---- text ------------------------------------------------------------ /// Set the UTF-8 content of a text node. Empty text nodes are excluded @@ -907,6 +1017,7 @@ impl Ui { &mut self.textures, &mut self.tex_free, &mut self.discs, + &self.particle_layers, &mut self.draw_list, self.inspect_id, self.inspect_drawn, diff --git a/core/src/tests.rs b/core/src/tests.rs index 2b04611..a0df285 100644 --- a/core/src/tests.rs +++ b/core/src/tests.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; -use crate::{spec, style, Ui}; +use crate::{spec, style, Particle, Ui}; // ---- binary blob builders (bytes hand-assembled per spec.ts formats) -------- @@ -243,6 +243,43 @@ fn validate_drawlist(words: &[u32]) -> [u32; 8] { // ---- tests --------------------------------------------------------------------- +#[test] +fn particle_layer_reuses_one_tree_node_and_cleans_up_with_it() { + let mut ui = Ui::new(); + let layer = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(layer, spec::prop::WIDTH, 100.0); + ui.set_prop(layer, spec::prop::HEIGHT, 100.0); + ui.insert_before(spec::ROOT_ID, layer, 0); + ui.set_particles( + layer, + &[ + Particle { x: 3.0, y: 5.0, size: 8.0, color: abgr(255, 0, 0, 255) }, + Particle { x: 20.0, y: 30.0, size: 4.0, color: abgr(0, 255, 0, 255) }, + ], + ); + ui.tick(); + assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 2); + + ui.set_particles(layer, &[Particle { x: 1.0, y: 2.0, size: 3.0, color: abgr(0, 0, 255, 255) }]); + assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 1); + + // Host bytes may begin at an unaligned address. Decode bytewise, then + // reject a corrupt giant size instead of covering the clipped field. + let mut packed = alloc::vec![0xaa]; + for word in [4.0f32.to_bits(), 6.0f32.to_bits(), 8.0f32.to_bits(), abgr(0, 255, 255, 255)] { + packed.extend_from_slice(&word.to_le_bytes()); + } + ui.set_particle_bytes(layer, &packed[1..], 1); + assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 1); + packed[9..13].copy_from_slice(&200.0f32.to_bits().to_le_bytes()); + ui.set_particle_bytes(layer, &packed[1..], 1); + assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::TEX_QUAD as usize], 0); + + ui.destroy_node(layer); + ui.tick(); + assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::RECT as usize], 0); +} + #[test] fn arena_id_reuse_and_stale_id_noop() { let mut ui = Ui::new(); diff --git a/demos/nightbloom/README.md b/demos/nightbloom/README.md index 0e438a1..cc8321e 100644 --- a/demos/nightbloom/README.md +++ b/demos/nightbloom/README.md @@ -43,22 +43,21 @@ that grows 52 -> 58 -> 66 px), announced by a flash ring and the boss's own cry — the diva chirps, the umbrella clangs. **The roster wakes as you play.** Only the CATNIP KIT answers at dusk — the -other two cards show `?`. Ascend once (stage II) and the sakura SAPLING -wakes; see the midboss off and the mountain answers. **And no pilot switches +gorilla card shows `?` until the midboss falls and the mountain answers. +**And no pilot switches itself**: when the piloted form dies the LAST BREATH opens — a 1.5 s window to switch to a waking form (O / L / R). Miss it, or die with nobody else awake, and the night takes the run. -## The roster — three forms, three answers +## The roster — two forms, two answers | form | wakes | shot | the ability | | --- | --- | --- | --- | | CATNIP KIT | at dusk | homing orbs | a black-and-gold moon cat, a white moon waxing on its brow; **dances with death** — wider graze ring, double graze glow | -| SAKURA SENTINEL | first ascension | petal fan, true damage | **the healer** — every damaging petal mends the most wounded waking form by 1; her hits are soft on purpose | -| MOON PRIMROSE | the midboss's fall | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | +| MOON PRIMROSE | the midboss's fall | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, every damaging touch heals the most wounded form by 2, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | -Spell cards double as bullet clears: NINE LIVES (homing burst), PETALFALL -(clear every shot on the field), MOONRISE (+100 glow to the whole waking +Spell cards reinforce each role: NINE LIVES (homing burst and nearby clear), +MOONRISE (heal 24 and +100 glow to the whole waking roster). The avatar is alive, not a decal: it breathes on a tick-driven bob, leans @@ -106,7 +105,7 @@ sink, and the simulation is byte-identical either way. `test/nightbloom.sim.test.ts` drives two tapes through the headless sim host: THE MARKSMAN, a full clear (~163 s — the catnip opens alone, the -night wakes the other two, every form flies and all three see the dawn), +midboss wakes the moon gorilla, and both forms see the dawn), and THE SLEEPER, whose lone catnip falls at ~34 s with nobody awake to switch to. It asserts repeat-identity, chaos immunity, strict 4 Hz / 2 Hz subsampling of both tapes, cross-rate byte-equal outcome diff --git a/demos/nightbloom/app.tsx b/demos/nightbloom/app.tsx index a797f8a..9a09eda 100644 --- a/demos/nightbloom/app.tsx +++ b/demos/nightbloom/app.tsx @@ -23,8 +23,9 @@ // gen-assets.ts. Every class is a FULL literal and all copy is ASCII (Inter // has no CJK). -import { For, Show } from "solid-js"; -import { Image, Screen, Text, View } from "@pocketjs/framework/components"; +import { For, Show, onCleanup } from "solid-js"; +import { Image, Screen, Text, View, type NodeMirror } from "@pocketjs/framework/components"; +import * as hot from "@pocketjs/framework/hot"; import { onFrame } from "@pocketjs/framework/lifecycle"; import { AVATAR_SIZE, @@ -39,7 +40,21 @@ import { SHOTS, WAVES, } from "./data.ts"; -import { createNightbloom, FX_LIFE, STAMP_AT, STAMP_IMPACT, type FloatFx, type Nightbloom, type PlantState } from "./engine.ts"; +import { + createNightbloom, + FX_LIFE, + MAX_ENEMY_SHOTS, + MAX_MOTES, + STAMP_AT, + STAMP_IMPACT, + type EnemyShot, + type EnemyShotKind, + type FloatFx, + type FoeInst, + type Nightbloom, + type PlantState, + type PlayerShot, +} from "./engine.ts"; // --------------------------------------------------------------------------- // Title / endings @@ -176,15 +191,30 @@ const STARS = [ { x: 14, y: 160, layer: 2 }, { x: 186, y: 220, layer: 2 }, { x: 70, y: 250, layer: 2 }, ]; -function Starfield(props: { game: Nightbloom }) { +function DeclarativeStarfield(props: { game: Nightbloom }) { const g = props.game; + const nodes: Array = []; + const syncStar = (i: number, tick: number) => { + const s = STARS[i]; + hot.prop(nodes[i], "translateY", (s.y + Math.floor(tick * (s.layer === 1 ? 0.35 : 0.7))) % FIELD.h); + }; + const syncStars = () => { + const tick = g.fxTick(); + for (let i = 0; i < STARS.length; i++) syncStar(i, tick); + }; + onFrame(syncStars); return ( - {(s) => ( + {(s, i) => ( { + const index = i(); + nodes[index] = node; + syncStar(index, g.fxTick()); + }} + style={{ insetL: s.x, insetT: 0, translateY: s.y }} /> )} @@ -192,8 +222,38 @@ function Starfield(props: { game: Nightbloom }) { ); } +function NativeStarfield(props: { game: Nightbloom }) { + let layer: NodeMirror | undefined; + const batch = hot.createParticleBatch(STARS.length); + const sync = () => { + const tick = props.game.fxTick(); + batch.reset(); + for (const star of STARS) { + const y = (star.y + Math.floor(tick * (star.layer === 1 ? 0.35 : 0.7))) % FIELD.h; + batch.push(star.x, y, 4, star.layer === 1 ? 0xff554133 : 0xff8b7464); + } + batch.flush(layer); + }; + onFrame(sync); + return ( + { + layer = node; + sync(); + }} + /> + ); +} + +function Starfield(props: { game: Nightbloom }) { + return hot.supportsParticles() ? : ; +} + function PlayerNode(props: { game: Nightbloom }) { const g = props.game; + let playerNode: NodeMirror | undefined; const sprite = () => { const p = g.active(); return PLANTS[p.kind].sprites[p.stage() - 1]; @@ -208,16 +268,26 @@ function PlayerNode(props: { game: Nightbloom }) { }; /** Lean into the strafe, danmaku-style. */ const lean = () => g.lastDx() * 9; + const syncPosition = () => { + const avatarSize = size(); + hot.position( + playerNode, + g.px() - FIELD.x0 - avatarSize / 2, + g.py() - FIELD.y0 - avatarSize / 2 + bob(), + ); + }; + onFrame(syncPosition); return ( { + playerNode = node; + syncPosition(); + }} style={{ - insetL: g.px() - FIELD.x0 - size() / 2, - insetT: g.py() - FIELD.y0 - size() / 2, width: size(), height: size(), - translateY: bob(), rotate: lean(), opacity: blink() ? 1 : 0.35, }} @@ -230,33 +300,139 @@ function PlayerNode(props: { game: Nightbloom }) { ); } -function FoeNode(props: { game: Nightbloom; foeId: number }) { - const g = props.game; - const foe = () => g.foes().find((f) => f.id === props.foeId); +interface MovingEntity { + id: number; + x: number; + y: number; +} + +interface Mover { + node: NodeMirror; + entity: MovingEntity; + offsetX: number; + offsetY: number; + spinOffset?: number; + index: number; +} + +type MoverRegistry = Mover[]; + +/** Register one plain-state swarm entity for the Field's single imperative + * motion pass. This avoids one Solid effect + style-object allocation per + * entity and uses paint-only transforms, so movement never dirties layout. */ +function moverRef( + movers: MoverRegistry, + entity: MovingEntity, + offsetX: number, + offsetY: number, + spinOffset?: number, +): (node: NodeMirror) => void { + let mover: Mover | undefined; + onCleanup(() => { + if (!mover) return; + const last = movers.pop(); + if (last && last !== mover) { + movers[mover.index] = last; + last.index = mover.index; + } + }); + return (node) => { + mover = { node, entity, offsetX, offsetY, spinOffset, index: movers.length }; + movers.push(mover); + }; +} + +function initialMotion(entity: MovingEntity, offsetX: number, offsetY: number): { translateX: number; translateY: number } { + return { + translateX: entity.x - FIELD.x0 + offsetX, + translateY: entity.y - FIELD.y0 + offsetY, + }; +} + +function FoeNode(props: { foe: FoeInst; movers: MoverRegistry }) { + const f = props.foe; + const def = FOES[f.kind]; return ( - - {(f) => { - const def = FOES[f.kind]; - return ( + + + + + + + ); +} + +const FOE_POOL_SIZE = 6; +const FOE_POOL = Array.from({ length: FOE_POOL_SIZE }, (_, i) => i); + +interface FoeSlot { + root?: NodeMirror; + image?: NodeMirror; + hp?: NodeMirror; +} + +/** PSP keeps foe structure stable across waves. Spawning, death, and escape + * only repaint these slots, avoiding a full Taffy rebuild on combat beats. */ +function NativeFoes(props: { game: Nightbloom; movers: MoverRegistry }) { + const slots: FoeSlot[] = FOE_POOL.map(() => ({})); + let visible = 0; + const sync = () => { + const foes = props.game.foes(); + const nextVisible = Math.min(foes.length, slots.length); + for (let i = 0; i < nextVisible; i++) { + const slot = slots[i]; + const foe = foes[i]; + const def = FOES[foe.kind]; + hot.image(slot.image, def.sprites[foe.stage - 1]); + hot.position(slot.root, foe.x - FIELD.x0 - 13, foe.y - FIELD.y0 - 13); + hot.prop(slot.hp, "scaleX", Math.max(0, foe.hp() / def.hp[foe.stage - 1])); + hot.prop(slot.root, "opacity", 1); + } + for (let i = nextVisible; i < visible; i++) hot.prop(slots[i].root, "opacity", 0); + visible = nextVisible; + }; + onFrame(sync); + return ( + <> + + {(i) => ( (slots[i].root = node)} + style={{ width: 26, height: 30, opacity: 0 }} > - + (slots[i].image = node)} /> (slots[i].hp = node)} /> - ); - }} - + )} + + + {(foe) => } + + ); } +function Foes(props: { game: Nightbloom; movers: MoverRegistry }) { + return hot.supportsParticles() + ? + : {(foe) => }; +} + function BossNode(props: { game: Nightbloom }) { const g = props.game; return ( @@ -304,65 +480,167 @@ function BossNode(props: { game: Nightbloom }) { ); } -function EnemyShotNode(props: { game: Nightbloom; shotId: number }) { - const g = props.game; - const shot = () => g.enemyShots().find((s) => s.id === props.shotId); - return ( - - {(s) => - s.kind === "mochi" ? ( - - ) : ( - - ) +function EnemyShotNode(props: { shot: EnemyShot; movers: MoverRegistry }) { + const s = props.shot; + return s.kind === "mochi" ? ( + + ) : ( + + nodeRef={moverRef(props.movers, s, -4, -4)} + style={initialMotion(s, -4, -4)} + /> ); } -function PlayerShotNode(props: { game: Nightbloom; shotId: number }) { - const g = props.game; - const shot = () => g.playerShots().find((s) => s.id === props.shotId); +/** ABGR colors matching the fallback Tailwind fills. Mochi becomes a native + * white dot on PSP so every trajectory fits one compact RECT batch. */ +const ENEMY_SHOT_COLORS: Record = { + pink: 0xffd4a8f9, + cyan: 0xfff9e867, + amber: 0xff4dd3fc, + mochi: 0xffffffff, +}; + +/** PSP: one retained node + one packed host call per frame. Other hosts keep + * the declarative nodes so browser rendering and deterministic tests need no + * new host capability. */ +function NativeEnemyShotLayer(props: { game: Nightbloom }) { + let layer: NodeMirror | undefined; + const batch = hot.createParticleBatch(MAX_ENEMY_SHOTS); + const sync = () => { + const shots = props.game.enemyShots(); + batch.reset(); + for (let i = 0; i < shots.length; i++) { + const shot = shots[i]; + batch.push(shot.x - FIELD.x0 - 4, shot.y - FIELD.y0 - 4, 8, ENEMY_SHOT_COLORS[shot.kind]); + } + batch.flush(layer); + }; + onFrame(sync); return ( - - {(s) => - s.kind === "petal" ? ( - - ) : s.kind === "banana" ? ( - - ) : ( - - ) - } - + { + layer = node; + sync(); + }} + /> + ); +} + +function EnemyShots(props: { game: Nightbloom; movers: MoverRegistry }) { + if (hot.supportsParticles()) return ; + return {(shot) => }; +} + +function PlayerShotNode(props: { shot: PlayerShot; movers: MoverRegistry }) { + const s = props.shot; + return s.kind === "banana" ? ( + + ) : ( + ); } +function NativePlayerShotLayer(props: { game: Nightbloom }) { + let layer: NodeMirror | undefined; + const batch = hot.createParticleBatch(40); + const sync = () => { + const shots = props.game.playerShots(); + batch.reset(); + for (let i = 0; i < shots.length; i++) { + const shot = shots[i]; + if (shot.kind === "banana") continue; + batch.push(shot.x - FIELD.x0 - 6, shot.y - FIELD.y0 - 6, 12, 0xffd4a8f9); + } + batch.flush(layer); + }; + onFrame(sync); + return ( + { + layer = node; + sync(); + }} + /> + ); +} + +function PlayerShots(props: { game: Nightbloom; movers: MoverRegistry }) { + if (!hot.supportsParticles()) { + return {(shot) => }; + } + return ( + <> + + shot.kind === "banana")}> + {(shot) => } + + + ); +} + +function MoteNode(props: { mote: MovingEntity; movers: MoverRegistry }) { + return ( + + ); +} + +function NativeMotes(props: { game: Nightbloom }) { + let layer: NodeMirror | undefined; + const batch = hot.createParticleBatch(MAX_MOTES); + const sync = () => { + const motes = props.game.motes(); + batch.reset(); + for (let i = 0; i < motes.length; i++) { + const mote = motes[i]; + batch.push(mote.x - FIELD.x0 - 3, mote.y - FIELD.y0 - 3, 6, 0xff7dd3fc); + } + batch.flush(layer); + }; + onFrame(sync); + return { layer = node; sync(); }} />; +} + +function Motes(props: { game: Nightbloom; movers: MoverRegistry }) { + return hot.supportsParticles() + ? + : {(mote) => }; +} + function FxNode(props: { game: Nightbloom; fx: FloatFx }) { const age = () => Math.min(1, Math.max(0, (props.game.fxTick() - props.fx.born) / FX_LIFE)); const cls = () => { @@ -383,6 +661,20 @@ function FxNode(props: { game: Nightbloom; fx: FloatFx }) { function Field(props: { game: Nightbloom }) { const g = props.game; + const movers: MoverRegistry = []; + onFrame(() => { + const spinTick = g.fxTick() * 9; + for (let i = 0; i < movers.length; i++) { + const mover = movers[i]; + hot.position( + mover.node, + mover.entity.x - FIELD.x0 + mover.offsetX, + mover.entity.y - FIELD.y0 + mover.offsetY, + ); + if (mover.spinOffset !== undefined) hot.prop(mover.node, "rotate", (spinTick + mover.spinOffset) % 360); + } + }); + onCleanup(() => (movers.length = 0)); return ( - - {(m) => ( - - )} - - {(f) => } + + - {(s) => } + - {(s) => } + {(f) => } game.frame(buttons)); return ( diff --git a/demos/nightbloom/data.ts b/demos/nightbloom/data.ts index fa760f9..0c68a60 100644 --- a/demos/nightbloom/data.ts +++ b/demos/nightbloom/data.ts @@ -86,10 +86,10 @@ const FOE_STYLE = const SHOT_STYLE = "tiny cute pixel art game projectile icon, rounded kawaii shape, soft glow, clean silhouette, centered"; // --------------------------------------------------------------------------- -// The roster — the five pilotable plant forms +// The roster — two pilotable plant forms // --------------------------------------------------------------------------- -export type PlantId = "primrose" | "catnip" | "sakura"; +export type PlantId = "primrose" | "catnip"; export interface SpellDef { name: string; @@ -115,7 +115,7 @@ export interface PlantDef { /** Shot damage per bullet and seconds between volleys, per stage. */ dmg: [number, number, number]; period: [number, number, number]; - /** Streams per volley, per stage (homing orbs / bolts / petal fan width). */ + /** Streams per volley, per stage. */ streams: [number, number, number]; /** Sprite file per stage — full literals so the pak build collects them. */ sprites: [string, string, string]; @@ -126,7 +126,7 @@ export interface PlantDef { spell: SpellDef; } -export const PLANT_ORDER: PlantId[] = ["catnip", "sakura", "primrose"]; +export const PLANT_ORDER: PlantId[] = ["catnip", "primrose"]; export const PLANTS: Record = { catnip: { @@ -145,22 +145,6 @@ export const PLANTS: Record = { law: "HOMING ORBS, AND IT DANCES WITH DEATH: WIDER GRAZE, DOUBLE GLOW", spell: { name: "NINE LIVES", hint: "9 HOMING ORBS + CLEAR NEAR", cooldown: 18 }, }, - sakura: { - id: "sakura", - name: "SAKURA SENTINEL", - stageNames: ["SAPLING", "GUARDIAN", "PETALSTORM"], - hp: [110, 140, 170], - armor: [0, 1, 2], - speed: 105, - artFacing: -1, - dmg: [3, 4, 5], - period: [0.34, 0.3, 0.26], - streams: [3, 5, 7], - sprites: ["p-sakura-1.png", "p-sakura-2.png", "p-sakura-3.png"], - evolveAt: [420, 1400], - law: "SOFT PETALS, TRUE DAMAGE -- AND EVERY HIT HEALS THE MOST WOUNDED", - spell: { name: "PETALFALL", hint: "CLEAR EVERY SHOT, SLOW ALL", cooldown: 15 }, - }, primrose: { id: "primrose", name: "MOON PRIMROSE", @@ -169,13 +153,13 @@ export const PLANTS: Record = { armor: [0, 0, 0], speed: 110, artFacing: -1, - dmg: [14, 18, 22], + dmg: [16, 20, 24], period: [0.42, 0.38, 0.34], streams: [1, 1, 1], sprites: ["p-primrose-1.png", "p-primrose-2.png", "p-primrose-3.png"], evolveAt: [360, 1200], - law: "BANANA BOOMERANGS: THREE ALOFT, CATCH THEM COMING BACK. MOTES x2", - spell: { name: "MOONRISE", hint: "+100 GLOW TO THE WHOLE ROSTER", cooldown: 15 }, + law: "BANANA BOOMERANGS: THREE ALOFT. EVERY HIT MENDS THE MOST WOUNDED. MOTES x2", + spell: { name: "MOONRISE", hint: "HEAL 24 + 100 GLOW TO THE WHOLE ROSTER", cooldown: 15 }, }, }; @@ -188,9 +172,9 @@ export const GRAZE_R = 11; /** The catnip dances with death: wider graze ring, double its glow. */ export const CATNIP_GRAZE_R = 16; export const CATNIP_GRAZE_MULT = 2; -/** Sakura's kindness: every damaging petal heals the most wounded waking - * form this much. */ -export const SAKURA_HEAL = 1; +/** The gorilla guards the garden: every damaging banana touch heals the + * most wounded waking form this much. */ +export const PRIMROSE_HEAL = 2; /** The gorilla's boomerangs: at most `max` aloft; thrown up at `throwVy` * px/s (per stage), decelerating `decel` px/s^2 until they turn, then * homing back at `back` px/s. Caught within `catchR` px. A banana never @@ -268,7 +252,7 @@ export const FOES: Record = { firePeriod: [2.2, 2.0, 1.8], shotSpeed: [66, 74, 82], sprites: ["f-kasa-1.png", "f-kasa-2.png", "f-kasa-3.png"], - law: "ARMORED SPREADS. PETALS PIERCE", + law: "ARMORED SPREADS. SPELL ORBS PIERCE", }, usagi: { id: "usagi", @@ -331,15 +315,15 @@ export interface WaveDef { } export const WAVES: WaveDef[] = [ - { at: 4, spawn: ["wisp", "wisp", "wisp"] }, - { at: 14, spawn: ["wisp", "wisp", "usagi"] }, - { at: 24, spawn: ["kasa", "wisp", "wisp"] }, - { at: 34, spawn: ["usagi", "usagi", "wisp", "wisp"] }, - { at: 44, spawn: ["uta", "wisp", "wisp", "wisp"] }, - { at: 56, spawn: ["wisp", "wisp", "wisp", "usagi", "usagi"] }, - { at: 66, spawn: ["kasa", "kasa", "uta"] }, - { at: 96, spawn: ["usagi", "usagi", "usagi", "wisp", "wisp"] }, - { at: 104, spawn: ["uta", "uta", "kasa"] }, + { at: 4, spawn: ["wisp", "usagi"] }, + { at: 14, spawn: ["wisp", "usagi"] }, + { at: 24, spawn: ["kasa", "wisp"] }, + { at: 34, spawn: ["usagi", "uta"] }, + { at: 44, spawn: ["uta", "kasa"] }, + { at: 56, spawn: ["wisp", "uta"] }, + { at: 66, spawn: ["kasa", "uta"] }, + { at: 96, spawn: ["usagi", "uta"] }, + { at: 104, spawn: ["uta", "kasa"] }, ]; /** The midboss crosses at this second (an IRON KASA grown monstrous). */ @@ -507,11 +491,6 @@ export const ART: ArtEntry[] = [ "the same black and gold cat grown into a young sleek two-tailed cat blossom, taller now, white crescent moon mark glowing on its forehead, golden bell collar, playful grin, two swishing leaf tails", "the same black and gold cat fully grown, a large regal spirit cat, many glowing petal tails fanned wide, a bright white full moon mark shining on its forehead, tiny golden crown, sparkling whiskers", ], 1030), - ...plantArt(PLANTS.sakura, [ - "tiny baby cherry blossom sapling, small and round with a shy blushing face and big soft eyes, a few pink petals drifting", - "the same cherry tree grown taller into a young blossom guardian, swirl of pink petals, calm smile", - "the same tree fully grown, a great wide sakura spirit in storm bloom, petal vortex, ancient serene face", - ], 1050), // --- foes (32x32, transparent, walk west) -------------------------------- ...foeArt(FOES.wisp, [ "floating paper lantern ghost with a tiny warm flame heart, chubby cheeks and a happy grin, soft ragged paper skirt", diff --git a/demos/nightbloom/engine.ts b/demos/nightbloom/engine.ts index 5525fc2..cd7d206 100644 --- a/demos/nightbloom/engine.ts +++ b/demos/nightbloom/engine.ts @@ -33,7 +33,7 @@ // reads nothing back — hosts without WebAudio never install one, and // the simulation is byte-identical either way. -import { createSignal, type Accessor } from "solid-js"; +import { batch, createSignal, type Accessor } from "solid-js"; import { after, ticksPerFrame } from "@pocketjs/framework/clock"; import { runEffect } from "@pocketjs/framework/effects"; import { BTN } from "@pocketjs/framework/input"; @@ -61,7 +61,7 @@ import { PLAYER_INSET, PLAYER_SPAWN, POC_Y, - SAKURA_HEAL, + PRIMROSE_HEAL, SWITCH_COOLDOWN, TPS, UTA_HASTE, @@ -135,13 +135,18 @@ export interface FoeInst { id: number; kind: FoeId; stage: number; - x: Cell; - y: Cell; + /** Plain fields, not cells: the swarm redraws off the per-tick fxTick + * signal, so per-entity position signals would only add QuickJS overhead + * (a getter call per read, a notification per write) in the hottest loops. */ + x: number; + y: number; hp: Cell; /** wisp/uta hover altitude; usagi weave direction lives in vx. */ hoverY: number; vx: number; fireCd: number; + /** Alternates the foe between its two attack skills. */ + skill: number; slowUntil: number; /** Hover ticks left before a wisp/uta drifts on down. */ station: number; @@ -168,25 +173,28 @@ export type EnemyShotKind = "pink" | "cyan" | "amber" | "mochi"; export interface EnemyShot { id: number; kind: EnemyShotKind; - x: Cell; - y: Cell; + x: number; + y: number; + /** Per-tick motion; enemy trajectories never steer after spawn. */ vx: number; vy: number; dmg: number; grazed: boolean; + /** Tick-local despawn marker; avoids Set allocation/hash work in QuickJS. */ + dead?: boolean; } -export type PlayerShotKind = "orb" | "petal" | "banana"; +export type PlayerShotKind = "orb" | "banana"; export interface PlayerShot { id: number; kind: PlayerShotKind; - x: Cell; - y: Cell; + x: number; + y: number; vx: number; vy: number; dmg: number; - /** True damage ignores armor (petals, spells). */ + /** True damage ignores armor (spell orbs). */ pierce: boolean; /** How many more bodies a bolt may pass through. */ through: number; @@ -198,12 +206,16 @@ export interface PlayerShot { hitCd: number; /** Roster index that fired it — glow is credited to the worker. */ owner: number; + /** Tick-local despawn marker; removed in one compacting filter pass. */ + dead?: boolean; } export interface MoteInst { id: number; - x: Cell; - y: Cell; + x: number; + y: number; + /** Tick-local despawn marker; removed in one compacting filter pass. */ + dead?: boolean; } export interface FloatFx { @@ -279,14 +291,14 @@ export interface Nightbloom { const BULLET_DMG = [9, 11, 13]; const RAM_DMG = 20; /** Bullet cap — spawns beyond this are skipped, deterministically. */ -const MAX_ENEMY_SHOTS = 72; +export const MAX_ENEMY_SHOTS = 48; /** Hovering foes (wisp, uta) stay on station this long, then drift on. */ const STATION_TICKS = 8 * TPS; /** The world scrolls on beneath everyone: even a hovering foe sinks with it, * so an unkilled monster always leaves the field eventually. */ const WORLD_DRIFT = 10 / TPS; const MAX_PLAYER_SHOTS = 40; -const MAX_MOTES = 24; +export const MAX_MOTES = 24; const SWITCH_TICKS = Math.round(SWITCH_COOLDOWN * TPS); const HURT_TICKS = Math.round(HURT_INVULN * TPS); @@ -295,7 +307,15 @@ const HURT_TICKS = Math.round(HURT_INVULN * TPS); export const STAMP_AT = 120; export const STAMP_IMPACT = 132; -export function createNightbloom(): Nightbloom { +export interface NightbloomOptions { + /** The PSP particle renderer samples shot arrays once per frame, so their + * hot membership updates can stay in place instead of feeding QuickJS's + * allocator and collector with short-lived array copies. */ + paintOnlyShots?: boolean; +} + +export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { + const paintOnlyShots = options.paintOnlyShots === true; const outcome = cell("title"); const paused = cell(false); const codexPage = cell(0); @@ -404,6 +424,7 @@ export function createNightbloom(): Nightbloom { epoch++; tick = 0; rng = NIGHT_SEED >>> 0; + pendingEnemyFire = []; nextWave = 0; nextPhase = 0; fireCd = 0; @@ -482,7 +503,6 @@ export function createNightbloom(): Nightbloom { toast(`${def.name} ASCENDS: ${def.stageNames[p.stage() - 1]}`); fx(px(), py() - 14, "UP!", "evolve"); sfx("evolve"); - if (p.stage() >= 2) unlock("sakura", "THE SAPLING WAKES -- SAKURA JOINS THE ROSTER"); } } @@ -497,12 +517,13 @@ export function createNightbloom(): Nightbloom { id: ++idSeq, kind, stage, - x: cell(slot), - y: cell(FIELD.y0 - 14), + x: slot, + y: FIELD.y0 - 14, hp: cell(def.hp[stage - 1]), hoverY: FIELD.y0 + 30 + rnd(74), vx: rnd(2) === 0 ? 1 : -1, fireCd: Math.round(def.firePeriod[stage - 1] * TPS * 0.6), + skill: 0, slowUntil: 0, station: STATION_TICKS, }, @@ -530,9 +551,28 @@ export function createNightbloom(): Nightbloom { toast(mid ? `${def.name} BARS THE WAY` : `${def.name} TAKES THE STAGE`); } + // Volleys land per burst, not per bullet: enemyFire fills a pending list + // and tickShots flushes it in ONE array set — same tick, same cap, same + // order as the old per-bullet sets, minus the per-bullet array copies. + let pendingEnemyFire: EnemyShot[] = []; + function enemyFire(x: number, y: number, vx: number, vy: number, kind: EnemyShotKind, dmg: number): void { - if (enemyShots().length >= MAX_ENEMY_SHOTS) return; - enemyShots.set([...enemyShots(), { id: ++idSeq, kind, x: cell(x), y: cell(y), vx, vy, dmg, grazed: false }]); + if (enemyShots().length + pendingEnemyFire.length >= MAX_ENEMY_SHOTS) return; + // Enemy velocity never changes after spawn. Store the per-tick delta once + // instead of dividing twice per bullet on every interpreted frame. + pendingEnemyFire.push({ id: ++idSeq, kind, x, y, vx: vx / TPS, vy: vy / TPS, dmg, grazed: false }); + } + + function flushEnemyFire(): void { + if (pendingEnemyFire.length === 0) return; + if (paintOnlyShots) { + const shots = enemyShots(); + for (const shot of pendingEnemyFire) shots.push(shot); + pendingEnemyFire.length = 0; + } else { + enemyShots.set([...enemyShots(), ...pendingEnemyFire]); + pendingEnemyFire = []; + } } function aimedAt(x: number, y: number, speed: number): { vx: number; vy: number } { @@ -543,18 +583,20 @@ export function createNightbloom(): Nightbloom { } function dropMotes(x: number, y: number, count: number): void { + const add: MoteInst[] = []; for (let i = 0; i < count; i++) { - if (motes().length >= MAX_MOTES) return; - motes.set([...motes(), { id: ++idSeq, x: cell(x + rnd(17) - 8), y: cell(y + rnd(9) - 4) }]); + if (motes().length + add.length >= MAX_MOTES) break; + add.push({ id: ++idSeq, x: x + rnd(17) - 8, y: y + rnd(9) - 4 }); } + if (add.length > 0) motes.set([...motes(), ...add]); } // -- damage ------------------------------------------------------------------ - /** Sakura's kindness: every damaging petal heals the most wounded waking - * form (self included) — the healer hits soft but keeps the roster alive. */ - function sakuraMend(owner: number): void { - if (roster[owner]?.kind !== "sakura") return; + /** The moon gorilla protects the whole roster. Every damaging banana touch + * mends the most wounded waking form, including itself. */ + function primroseMend(owner: number): void { + if (roster[owner]?.kind !== "primrose") return; let target: PlantState | null = null; let worst = 1; for (const r of roster) { @@ -566,23 +608,25 @@ export function createNightbloom(): Nightbloom { } } if (!target) return; - target.hp.set(Math.min(PLANTS[target.kind].hp[target.stage() - 1], target.hp() + SAKURA_HEAL)); + target.hp.set(Math.min(PLANTS[target.kind].hp[target.stage() - 1], target.hp() + PRIMROSE_HEAL)); sfx("heal"); } function hitFoe(f: FoeInst, dmg: number, pierce: boolean, owner: number): void { - if (!foes().some((x) => x.id === f.id)) return; + // A tick walks a stable foe snapshot. Slain entries stay in that snapshot + // until the pass ends, and hp is its cheaper membership/death sentinel. + if (f.hp() <= 0) return; const def = FOES[f.kind]; const eff = pierce ? dmg : Math.max(1, dmg - def.armor[f.stage - 1]); f.hp.set(f.hp() - eff); sfx("hit"); - sakuraMend(owner); + primroseMend(owner); const p = roster[owner]; if (p) grantGlow(p, eff); if (f.hp() <= 0) { kills.set(kills() + 1); score.set(score() + 100); - dropMotes(f.x(), f.y(), def.bounty[f.stage - 1]); + dropMotes(f.x, f.y, def.bounty[f.stage - 1]); foes.set(foes().filter((x) => x.id !== f.id)); sfx("kill"); } @@ -592,7 +636,7 @@ export function createNightbloom(): Nightbloom { if (boss() !== b) return; b.hp.set(b.hp() - dmg); sfx("hit"); - sakuraMend(owner); + primroseMend(owner); const p = roster[owner]; if (p) grantGlow(p, dmg); if (b.hp() <= 0) advanceBoss(b, true); @@ -695,24 +739,16 @@ export function createNightbloom(): Nightbloom { const s = p.stage() - 1; if (playerShots().length >= MAX_PLAYER_SHOTS) return; const shots = playerShots(); - const add: PlayerShot[] = []; const owner = activeIdx(); const streams = def.streams[s]; + const add: PlayerShot[] = []; if (p.kind === "catnip") { for (let i = 0; i < streams; i++) { add.push({ - id: ++idSeq, kind: "orb", x: cell(px() + (i - (streams - 1) / 2) * 10), y: cell(py() - 10), + id: ++idSeq, kind: "orb", x: px() + (i - (streams - 1) / 2) * 10, y: py() - 10, vx: 0, vy: -170, dmg: def.dmg[s], pierce: false, through: 0, homing: true, ret: false, hitCd: 0, owner, }); } - } else if (p.kind === "sakura") { - for (let i = 0; i < streams; i++) { - const a = -16 + (i - (streams - 1) / 2) * 2; // fan around straight up - add.push({ - id: ++idSeq, kind: "petal", x: cell(px()), y: cell(py() - 10), - vx: cosA(a) * 150, vy: sinA(a) * 150, dmg: def.dmg[s], pierce: true, through: 0, homing: false, ret: false, hitCd: 0, owner, - }); - } } else { // the gorilla: banana boomerangs — at most BANANA.max aloft, and a // throw only leaves a hand that holds one @@ -722,12 +758,18 @@ export function createNightbloom(): Nightbloom { return; } add.push({ - id: ++idSeq, kind: "banana", x: cell(px()), y: cell(py() - 12), + id: ++idSeq, kind: "banana", x: px(), y: py() - 12, vx: 0, vy: -BANANA.throwVy[s], dmg: def.dmg[s], pierce: false, through: 0, homing: false, ret: false, hitCd: 0, owner, }); } - playerShots.set([...shots, ...add]); + if (paintOnlyShots && p.kind !== "primrose") { + for (const shot of add) shots.push(shot); + } else { + // Bananas remain declarative on PSP, so their membership must still + // notify Solid's . Browser/test hosts always retain this path. + playerShots.set([...shots, ...add]); + } fireCd = Math.round(def.period[s] * TPS); sfx("shoot"); } @@ -741,27 +783,29 @@ export function createNightbloom(): Nightbloom { const add: PlayerShot[] = []; for (let i = 0; i < 9; i++) { add.push({ - id: ++idSeq, kind: "orb", x: cell(px()), y: cell(py() - 8), + id: ++idSeq, kind: "orb", x: px(), y: py() - 8, vx: cosA(-32 + i * 7) * 120, vy: sinA(-32 + i * 7) * 120 - 60, dmg: 24, pierce: true, through: 0, homing: true, ret: false, hitCd: 0, owner, }); } - playerShots.set([...playerShots(), ...add]); + if (paintOnlyShots) { + const shots = playerShots(); + for (const shot of add) shots.push(shot); + } else { + playerShots.set([...playerShots(), ...add]); + } enemyShots.set(enemyShots().filter((sh) => { - const dx = sh.x() - px(); - const dy = sh.y() - py(); + const dx = sh.x - px(); + const dy = sh.y - py(); return dx * dx + dy * dy > 70 * 70; })); - } else if (p.kind === "sakura") { - for (const f of [...foes()]) { - hitFoe(f, 18, true, owner); - f.slowUntil = tick + 2 * TPS; - } - const b = boss(); - if (b) hitBoss(b, 18, owner); - enemyShots.set([]); } else { - for (const r of roster) if (ready(r)) grantGlow(r, 100); + for (const r of roster) { + if (!ready(r)) continue; + grantGlow(r, 100); + r.hp.set(Math.min(PLANTS[r.kind].hp[r.stage() - 1], r.hp() + 24)); + } + sfx("heal"); } toast(`SPELL CARD: ${def.spell.name}`); sfx("spell"); @@ -840,71 +884,116 @@ export function createNightbloom(): Nightbloom { } function tickFoes(): void { - const hasUta = foes().some((f) => f.kind === "uta"); - for (const f of [...foes()]) { - if (!foes().some((x) => x.id === f.id)) continue; + const fs = foes(); // escape removals swap in a fresh array; fs is the tick's snapshot + const hasUta = fs.some((f) => f.kind === "uta"); + for (const f of fs) { const def = FOES[f.kind]; const s = f.stage - 1; const slowed = tick < f.slowUntil; const rate = slowed ? 0.6 : 1; const spd = (def.speed[s] / TPS) * rate; if (f.kind === "usagi") { - f.x.set(f.x() + f.vx * spd); - f.y.set(f.y() + spd * 0.35); - if (f.x() < FIELD.x0 + 14) f.vx = 1; - if (f.x() > FIELD.x0 + FIELD.w - 14) f.vx = -1; + f.x += f.vx * spd; + f.y += spd * 0.35; + if (f.x < FIELD.x0 + 14) f.vx = 1; + if (f.x > FIELD.x0 + FIELD.w - 14) f.vx = -1; } else if (f.kind === "wisp" || f.kind === "uta") { - if (f.y() < f.hoverY) { - f.y.set(f.y() + spd); + if (f.y < f.hoverY) { + f.y += spd; } else if (f.station > 0) { f.station--; - f.x.set(f.x() + f.vx * spd * 0.5); - f.y.set(f.y() + WORLD_DRIFT); // the view slides forward regardless + f.x += f.vx * spd * 0.5; + f.y += WORLD_DRIFT; // the view slides forward regardless } else { - f.y.set(f.y() + spd * 1.4); // the song moves on + f.y += spd * 1.4; // the song moves on } - if (f.x() < FIELD.x0 + 14) f.vx = 1; - if (f.x() > FIELD.x0 + FIELD.w - 14) f.vx = -1; + if (f.x < FIELD.x0 + 14) f.vx = 1; + if (f.x > FIELD.x0 + FIELD.w - 14) f.vx = -1; } else { - f.y.set(f.y() + spd); + f.y += spd; } - if (f.y() > FIELD.y0 + FIELD.h + 18) { + if (f.y > FIELD.y0 + FIELD.h + 18) { foes.set(foes().filter((x) => x.id !== f.id)); // it drifts past the garden escaped.set(escaped() + 1); continue; } // fire f.fireCd -= hasUta && f.kind !== "uta" ? 1 / UTA_HASTE : 1; - if (f.fireCd <= 0 && f.y() > FIELD.y0 + 6) { + if (f.fireCd <= 0 && f.y > FIELD.y0 + 6) { f.fireCd = Math.round(def.firePeriod[s] * TPS * (slowed ? 1.6 : 1)); const dmg = BULLET_DMG[s]; const shotSpeed = def.shotSpeed[s]; + const skill = f.skill++ & 1; if (f.kind === "wisp") { - const n = f.stage; - for (let i = 0; i < n; i++) { - const v = aimedAt(f.x(), f.y(), shotSpeed); - const a = (i - (n - 1) / 2) * 3; - enemyFire( - f.x(), f.y() + 8, - v.vx * cosA(a) - v.vy * sinA(a), - v.vx * sinA(a) + v.vy * cosA(a), - "cyan", dmg, - ); + if (skill === 0) { + const n = f.stage; + for (let i = 0; i < n; i++) { + const v = aimedAt(f.x, f.y, shotSpeed); + const a = (i - (n - 1) / 2) * 3; + enemyFire( + f.x, f.y + 8, + v.vx * cosA(a) - v.vy * sinA(a), + v.vx * sinA(a) + v.vy * cosA(a), + "cyan", dmg, + ); + } + } else { + const n = 3 + f.stage * 2; + for (let i = 0; i < n; i++) { + const a = A_DOWN + (i - (n - 1) / 2) * 5; + enemyFire(f.x, f.y + 8, cosA(a) * shotSpeed * 0.82, sinA(a) * shotSpeed * 0.82, "pink", dmg); + } } } else if (f.kind === "kasa") { - const n = 3 + f.stage * 2; - for (let i = 0; i < n; i++) { - const a = A_DOWN + (i - (n - 1) / 2) * 4; - enemyFire(f.x(), f.y() + 8, cosA(a) * shotSpeed, sinA(a) * shotSpeed, "amber", dmg); + if (skill === 0) { + const n = 3 + f.stage * 2; + for (let i = 0; i < n; i++) { + const a = A_DOWN + (i - (n - 1) / 2) * 4; + enemyFire(f.x, f.y + 8, cosA(a) * shotSpeed, sinA(a) * shotSpeed, "amber", dmg); + } + } else { + const v = aimedAt(f.x, f.y, shotSpeed * 1.08); + for (let i = -1; i <= 1; i++) { + enemyFire( + f.x, f.y + 8, + v.vx * cosA(i * 3) - v.vy * sinA(i * 3), + v.vx * sinA(i * 3) + v.vy * cosA(i * 3), + "cyan", dmg, + ); + } } } else if (f.kind === "usagi") { - const v = aimedAt(f.x(), f.y(), shotSpeed); - enemyFire(f.x(), f.y() + 8, v.vx, v.vy, "mochi", dmg); + const v = aimedAt(f.x, f.y, shotSpeed); + if (skill === 0) { + enemyFire(f.x, f.y + 8, v.vx, v.vy, "mochi", dmg); + } else { + for (const a of [-4, 4]) { + enemyFire( + f.x, f.y + 8, + v.vx * cosA(a) - v.vy * sinA(a), + v.vx * sinA(a) + v.vy * cosA(a), + "cyan", dmg, + ); + } + } } else { - const n = 6 + f.stage * 2; - for (let i = 0; i < n; i++) { - const a = Math.round((i * 64) / n) + ((tick >> 4) % 64); - enemyFire(f.x(), f.y(), cosA(a) * shotSpeed, sinA(a) * shotSpeed, "pink", dmg); + if (skill === 0) { + const n = 6 + f.stage * 2; + for (let i = 0; i < n; i++) { + const a = Math.round((i * 64) / n) + ((tick >> 4) % 64); + enemyFire(f.x, f.y, cosA(a) * shotSpeed, sinA(a) * shotSpeed, "pink", dmg); + } + } else { + const v = aimedAt(f.x, f.y, shotSpeed * 1.12); + for (let i = -1; i <= 1; i++) { + const a = i * 3; + enemyFire( + f.x, f.y + 8, + v.vx * cosA(a) - v.vy * sinA(a), + v.vx * sinA(a) + v.vy * cosA(a), + "amber", dmg, + ); + } } } } @@ -1001,28 +1090,40 @@ export function createNightbloom(): Nightbloom { } function tickShots(): void { + // this tick's volleys land in one array set, then the arrays are stable + // for the whole pass; despawns mark objects and compact once per array + flushEnemyFire(); + const pxv = px(); // the pilot does not move inside tickShots + const pyv = py(); + // player shots - for (const sh of [...playerShots()]) { + const pShots = playerShots(); + const foeSnapshot = foes(); + let targetBoss = boss(); + let removedP = false; + let removedBanana = false; + for (const sh of pShots) { if (sh.kind === "banana") { // out, turn, home, and into the hand if (!sh.ret) { sh.vy += BANANA.decel / TPS; if (sh.vy >= 0) sh.ret = true; } else { - const dx = px() - sh.x(); - const dy = py() - sh.y(); + const dx = pxv - sh.x; + const dy = pyv - sh.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; sh.vx = (dx / len) * BANANA.back; sh.vy = (dy / len) * BANANA.back; } - sh.x.set(sh.x() + sh.vx / TPS); - sh.y.set(sh.y() + sh.vy / TPS); + sh.x += sh.vx / TPS; + sh.y += sh.vy / TPS; if (sh.hitCd > 0) sh.hitCd--; if (sh.hitCd <= 0) { let struck = false; - for (const f of [...foes()]) { - const fdx = f.x() - sh.x(); - const fdy = f.y() - sh.y(); + for (const f of foeSnapshot) { + if (f.hp() <= 0) continue; + const fdx = f.x - sh.x; + const fdy = f.y - sh.y; if (fdx * fdx + fdy * fdy <= 13 * 13) { hitFoe(f, sh.dmg, sh.pierce, sh.owner); struck = true; @@ -1030,13 +1131,14 @@ export function createNightbloom(): Nightbloom { } } if (!struck) { - const b = boss(); + const b = targetBoss; if (b) { const br = b.def.phases[b.phase()].size * 0.4; - const bdx = b.x() - sh.x(); - const bdy = b.y() - sh.y(); + const bdx = b.x() - sh.x; + const bdy = b.y() - sh.y; if (bdx * bdx + bdy * bdy <= br * br) { hitBoss(b, sh.dmg, sh.owner); + if (boss() !== b) targetBoss = null; struck = true; } } @@ -1044,10 +1146,12 @@ export function createNightbloom(): Nightbloom { if (struck) sh.hitCd = BANANA.hitCd; } if (sh.ret) { - const cdx = px() - sh.x(); - const cdy = py() - sh.y(); + const cdx = pxv - sh.x; + const cdy = pyv - sh.y; if (cdx * cdx + cdy * cdy <= BANANA.catchR * BANANA.catchR) { - playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + sh.dead = true; + removedP = true; + removedBanana = true; sfx("mote"); // back in the hand } } @@ -1058,20 +1162,21 @@ export function createNightbloom(): Nightbloom { let tx = 0; let ty = 0; let best = Infinity; - for (const f of foes()) { - const dx = f.x() - sh.x(); - const dy = f.y() - sh.y(); + for (const f of foeSnapshot) { + if (f.hp() <= 0) continue; + const dx = f.x - sh.x; + const dy = f.y - sh.y; const d = dx * dx + dy * dy; if (d < best) { best = d; - tx = f.x(); - ty = f.y(); + tx = f.x; + ty = f.y; } } - const b = boss(); + const b = targetBoss; if (b) { - const dx = b.x() - sh.x(); - const dy = b.y() - sh.y(); + const dx = b.x() - sh.x; + const dy = b.y() - sh.y; const d = dx * dx + dy * dy; if (d < best) { best = d; @@ -1081,8 +1186,8 @@ export function createNightbloom(): Nightbloom { } if (best < Infinity) { const cur = Math.sqrt(sh.vx * sh.vx + sh.vy * sh.vy) || 1; - const dx = tx - sh.x(); - const dy = ty - sh.y(); + const dx = tx - sh.x; + const dy = ty - sh.y; const dl = Math.sqrt(dx * dx + dy * dy) || 1; const nvx = sh.vx * 0.88 + (dx / dl) * cur * 0.12; const nvy = sh.vy * 0.88 + (dy / dl) * cur * 0.12; @@ -1091,20 +1196,22 @@ export function createNightbloom(): Nightbloom { sh.vy = (nvy / nl) * cur; } } - sh.x.set(sh.x() + sh.vx / TPS); - sh.y.set(sh.y() + sh.vy / TPS); + sh.x += sh.vx / TPS; + sh.y += sh.vy / TPS; if ( - sh.y() < FIELD.y0 - 16 || sh.y() > FIELD.y0 + FIELD.h + 16 || - sh.x() < FIELD.x0 - 16 || sh.x() > FIELD.x0 + FIELD.w + 16 + sh.y < FIELD.y0 - 16 || sh.y > FIELD.y0 + FIELD.h + 16 || + sh.x < FIELD.x0 - 16 || sh.x > FIELD.x0 + FIELD.w + 16 ) { - playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + sh.dead = true; + removedP = true; continue; } // hit foes let spent = false; - for (const f of [...foes()]) { - const dx = f.x() - sh.x(); - const dy = f.y() - sh.y(); + for (const f of foeSnapshot) { + if (f.hp() <= 0) continue; + const dx = f.x - sh.x; + const dy = f.y - sh.y; if (dx * dx + dy * dy <= 13 * 13) { hitFoe(f, sh.dmg, sh.pierce, sh.owner); if (sh.through > 0) { @@ -1116,78 +1223,111 @@ export function createNightbloom(): Nightbloom { } } if (!spent) { - const b = boss(); + const b = targetBoss; if (b) { const br = b.def.phases[b.phase()].size * 0.4; - const dx = b.x() - sh.x(); - const dy = b.y() - sh.y(); + const dx = b.x() - sh.x; + const dy = b.y() - sh.y; if (dx * dx + dy * dy <= br * br) { hitBoss(b, sh.dmg, sh.owner); + if (boss() !== b) targetBoss = null; spent = true; } } } - if (spent) playerShots.set(playerShots().filter((x) => x.id !== sh.id)); + if (spent) { + sh.dead = true; + removedP = true; + } + } + if (removedP) { + if (paintOnlyShots && !removedBanana) { + let write = 0; + for (const shot of pShots) if (!shot.dead) pShots[write++] = shot; + pShots.length = write; + } else { + playerShots.set(playerShots().filter((x) => !x.dead)); + } } - // enemy shots - for (const sh of [...enemyShots()]) { - sh.x.set(sh.x() + sh.vx / TPS); - sh.y.set(sh.y() + sh.vy / TPS); + // enemy shots (captured after the player pass: a card break mid-pass + // clears the sky, and this snapshot must see that) + const eShots = enemyShots(); + const act = active(); + const grazeR = act.kind === "catnip" ? CATNIP_GRAZE_R : GRAZE_R; + let removedE = false; + for (const sh of eShots) { + sh.x += sh.vx; + sh.y += sh.vy; if ( - sh.y() > FIELD.y0 + FIELD.h + 12 || sh.y() < FIELD.y0 - 12 || - sh.x() < FIELD.x0 - 12 || sh.x() > FIELD.x0 + FIELD.w + 12 + sh.y > FIELD.y0 + FIELD.h + 12 || sh.y < FIELD.y0 - 12 || + sh.x < FIELD.x0 - 12 || sh.x > FIELD.x0 + FIELD.w + 12 ) { - enemyShots.set(enemyShots().filter((x) => x.id !== sh.id)); + sh.dead = true; + removedE = true; continue; } - const dx = sh.x() - px(); - const dy = sh.y() - py(); + const dx = sh.x - pxv; + const dy = sh.y - pyv; const d2 = dx * dx + dy * dy; const hitR = HIT_R + 3; if (d2 <= hitR * hitR) { - enemyShots.set(enemyShots().filter((x) => x.id !== sh.id)); + sh.dead = true; + removedE = true; hurtPlayer(sh.dmg); } else { - const gr = active().kind === "catnip" ? CATNIP_GRAZE_R : GRAZE_R; - if (!sh.grazed && d2 <= gr * gr && invulnTicks <= 0) { + if (!sh.grazed && d2 <= grazeR * grazeR && invulnTicks <= 0) { sh.grazed = true; graze.set(graze() + 1); score.set(score() + 10); - grantGlow(active(), GRAZE_GLOW * (active().kind === "catnip" ? CATNIP_GRAZE_MULT : 1)); + grantGlow(act, GRAZE_GLOW * (act.kind === "catnip" ? CATNIP_GRAZE_MULT : 1)); sfx("graze"); } } } + if (removedE) { + if (paintOnlyShots) { + let write = 0; + for (const shot of eShots) if (!shot.dead) eShots[write++] = shot; + eShots.length = write; + } else { + enemyShots.set(enemyShots().filter((x) => !x.dead)); + } + } // body rams - for (const f of foes()) { - const dx = f.x() - px(); - const dy = f.y() - py(); + for (const f of foeSnapshot) { + if (f.hp() <= 0) continue; + const dx = f.x - pxv; + const dy = f.y - pyv; if (dx * dx + dy * dy <= 14 * 14) hurtPlayer(RAM_DMG); } // motes - for (const m of [...motes()]) { - if (py() < POC_Y || Math.abs(m.x() - px()) + Math.abs(m.y() - py()) < 34) { + const ms = motes(); + let removedM = false; + for (const m of ms) { + if (pyv < POC_Y || Math.abs(m.x - pxv) + Math.abs(m.y - pyv) < 34) { // magnet: above the PoC line, or close by - const dx = px() - m.x(); - const dy = py() - m.y(); + const dx = pxv - m.x; + const dy = pyv - m.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; - m.x.set(m.x() + (dx / len) * (220 / TPS)); - m.y.set(m.y() + (dy / len) * (220 / TPS)); + m.x += (dx / len) * (220 / TPS); + m.y += (dy / len) * (220 / TPS); } else { - m.y.set(m.y() + 44 / TPS); + m.y += 44 / TPS; } - if (m.y() > FIELD.y0 + FIELD.h + 10) { - motes.set(motes().filter((x) => x.id !== m.id)); + if (m.y > FIELD.y0 + FIELD.h + 10) { + m.dead = true; + removedM = true; motesMissed.set(motesMissed() + 1); continue; } - const dx = m.x() - px(); - const dy = m.y() - py(); + const dx = m.x - pxv; + const dy = m.y - pyv; if (dx * dx + dy * dy <= 12 * 12) { - motes.set(motes().filter((x) => x.id !== m.id)); + m.dead = true; + removedM = true; const p = active(); const worth = p.kind === "primrose" ? MOTE_GLOW * 2 : MOTE_GLOW; grantGlow(p, worth); @@ -1195,6 +1335,7 @@ export function createNightbloom(): Nightbloom { sfx("mote"); } } + if (removedM) motes.set(motes().filter((x) => !x.dead)); } function stepTick(pressed: number, held: number): void { @@ -1219,7 +1360,13 @@ export function createNightbloom(): Nightbloom { // Frame-boundary rule (the subsampling contract): a battle frame either // runs its FULL ticksPerFrame() batch or none of it — see the header. + // The whole frame runs inside one Solid batch: render effects fire once, + // with final values, after the last micro-tick — not per signal write. function frame(buttons: number): void { + batch(() => frameBody(buttons)); + } + + function frameBody(buttons: number): void { const pressed = buttons & ~prevButtons; prevButtons = buttons; diff --git a/native/src/ffi.rs b/native/src/ffi.rs index ab00ec7..b464c80 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -166,6 +166,47 @@ unsafe extern "C" fn js_set_prop( JS_UNDEFINED } +/// PSP-only HostOps acceleration for dense moving swarms. Translation is +/// paint-only, so the two ordinary core writes do not invalidate layout; the +/// win is crossing the QuickJS C boundary once instead of twice per node. +unsafe extern "C" fn js_set_translation( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + let id = arg_i32(ctx, argc, argv, 0); + ui().set_prop( + id, + pocketjs_core::spec::prop::TRANSLATE_X, + arg_f64(ctx, argc, argv, 1), + ); + ui().set_prop( + id, + pocketjs_core::spec::prop::TRANSLATE_Y, + arg_f64(ctx, argc, argv, 2), + ); + JS_UNDEFINED +} + +unsafe extern "C" fn js_set_particles( + ctx: *mut JSContext, + _this: JSValue, + argc: i32, + argv: *mut JSValue, +) -> JSValue { + if argc < 3 { + return JS_UNDEFINED; + } + let id = arg_i32(ctx, argc, argv, 0); + let Some((bytes, len)) = buffer_bytes(ctx, *argv.offset(1)) else { + return JS_UNDEFINED; + }; + let count = arg_i32(ctx, argc, argv, 2).max(0) as usize; + ui().set_particle_bytes(id, core::slice::from_raw_parts(bytes, len), count); + JS_UNDEFINED +} + /// Shared body of setText/replaceText (identical core semantics). unsafe fn set_text_impl(ctx: *mut JSContext, argc: i32, argv: *mut JSValue) -> JSValue { if argc < 2 { @@ -562,6 +603,8 @@ pub unsafe fn register( add_fn(ctx, ui_obj, b"removeChild\0", js_remove_child, 2); add_fn(ctx, ui_obj, b"setStyle\0", js_set_style, 2); add_fn(ctx, ui_obj, b"setProp\0", js_set_prop, 3); + add_fn(ctx, ui_obj, b"setTranslation\0", js_set_translation, 3); + add_fn(ctx, ui_obj, b"setParticles\0", js_set_particles, 3); add_fn(ctx, ui_obj, b"setText\0", js_set_text, 2); add_fn(ctx, ui_obj, b"replaceText\0", js_replace_text, 2); add_fn(ctx, ui_obj, b"uploadTexture\0", js_upload_texture, 4); diff --git a/scripts/bench-ppsspp.ts b/scripts/bench-ppsspp.ts index 57b3830..26617fb 100644 --- a/scripts/bench-ppsspp.ts +++ b/scripts/bench-ppsspp.ts @@ -33,6 +33,8 @@ interface BenchLine { avg_render_us: number; avg_work_us: number; max_work_us: number; + avg_gpu_us: number; + max_gpu_us: number; bundle_bytes: number; pak_bytes: number; arena_capacity_bytes: number; @@ -89,6 +91,16 @@ const SPECS: Spec[] = [ capStart: 0, capN: 95, }, + { + // Three trajectory families overlap here: lingering aimed wisp/usagi + // fire plus the 24 s kasa spread. The sweep tape keeps the pilot alive + // and firing so collision, player-shot and swarm rendering costs coexist. + app: "nightbloom", + inputScript: + "0:0,1:0x0008,2:0x40,90:0xc0,180:0x60,270:0xc0,360:0x60,450:0xc0,540:0x60,630:0xc0,720:0x60,810:0xc0,900:0x60,990:0xc0,1080:0x60,1170:0xc0,1260:0x60,1350:0xc0,1440:0x60,1530:0xc0,1620:0x60", + capStart: 1440, + capN: 180, + }, ]; const pspUiDir = new URL("..", import.meta.url).pathname; @@ -181,6 +193,8 @@ const METRICS = [ "avg_render_us", "avg_work_us", "max_work_us", + "avg_gpu_us", + "max_gpu_us", "host_wall_ms", "bundle_bytes", "pak_bytes", diff --git a/src/host.ts b/src/host.ts index 19674da..f8e6207 100644 --- a/src/host.ts +++ b/src/host.ts @@ -34,6 +34,13 @@ export interface HostOps { setStyle(id: number, styleId: number): void; /** propId: spec PROP. Colors/enums pass their u32 bits as a number. */ setProp(id: number, propId: number, value: number): void; + /** Optional hot-path fusion for paint-only translateX + translateY. PSP + * exposes this to halve QuickJS -> native calls for large moving swarms; + * runtimes without it fall back to two setProp calls in hot.position. */ + setTranslation?(id: number, x: number, y: number): void; + /** Optional paint-only particle layer. Words repeat + * [x:f32, y:f32, size:f32, color:u32 ABGR]. */ + setParticles?(id: number, words: Uint32Array, count: number): void; /** UTF-8 text; text nodes only. */ setText(id: number, str: string): void; /** Solid universal calls this on reactive text updates. */ diff --git a/src/hot.ts b/src/hot.ts index 56cb170..2ed1926 100644 --- a/src/hot.ts +++ b/src/hot.ts @@ -8,9 +8,9 @@ // signal with four subscribers, every rifle shot. A per-frame game value // (ammo counter, health bar fill) cannot afford it. // -// `hot.text` / `hot.prop` write straight to the native ops with a per-node -// last-value gate: an unchanged value costs one comparison, a changed one -// costs one FFI call (~50 µs on hardware). Rules: +// `hot.text` / `hot.prop` / `hot.position` write straight to the native ops +// with a per-node last-value gate: an unchanged value costs one comparison, +// a changed value costs one FFI call (~50 µs on hardware). Rules: // // - a hot-driven value must NOT also have a Solid binding (two writers, // last one wins — keep the JSX side a static initial value); @@ -25,10 +25,17 @@ import { NODE_TYPE, PROP, type PropName } from "../spec/spec.ts"; import { encodePropValue, getOps } from "./host.ts"; -import type { NodeMirror } from "./native-tree.ts"; +import { setProp as setNodeProp, type NodeMirror } from "./native-tree.ts"; const lastText = new WeakMap(); -const lastProp = new WeakMap>(); +type HotNode = NodeMirror & { __pocketHotProps?: Record; __pocketHotImage?: string }; + +/** Hot prop state belongs to the mirror node and dies with it. A direct field + * is materially cheaper than a WeakMap lookup in QuickJS's per-frame loops. */ +function propCache(node: NodeMirror): Record { + const hotNode = node as HotNode; + return hotNode.__pocketHotProps ??= {}; +} /** The text-run node under `node`: itself, or its first text child (the * node Solid's `{expr}` insert created). */ @@ -56,11 +63,7 @@ export function text(node: NodeMirror | undefined, value: string | number): void /** Imperatively set a numeric style prop (opacity, scaleX, translateX, …). */ export function prop(node: NodeMirror | undefined, name: PropName, value: number): void { if (!node) return; - let cache = lastProp.get(node); - if (cache === undefined) { - cache = {}; - lastProp.set(node, cache); - } + const cache = propCache(node); if (cache[name] === value) return; cache[name] = value; const propId = PROP[name]; @@ -69,3 +72,68 @@ export function prop(node: NodeMirror | undefined, name: PropName, value: number } getOps().setProp(node.id, propId, encodePropValue(name, value)); } + +/** Imperatively set paint-only translateX + translateY. PSP fuses the pair + * into one QuickJS -> native call; other hosts preserve identical semantics + * through the ordinary setProp path. */ +export function position(node: NodeMirror | undefined, x: number, y: number): void { + if (!node) return; + const cache = propCache(node); + if (cache.translateX === x && cache.translateY === y) return; + cache.translateX = x; + cache.translateY = y; + const ops = getOps(); + if (ops.setTranslation) { + ops.setTranslation(node.id, x, y); + return; + } + ops.setProp(node.id, PROP.translateX, x); + ops.setProp(node.id, PROP.translateY, y); +} + +/** Swap an image texture without involving a reactive JSX binding. Image + * selection is paint-only in the retained core. */ +export function image(node: NodeMirror | undefined, src: string): void { + if (!node) return; + const hotNode = node as HotNode; + if (hotNode.__pocketHotImage === src) return; + setNodeProp(node, "src", src, hotNode.__pocketHotImage); + hotNode.__pocketHotImage = src; +} + +export function supportsParticles(): boolean { + return getOps().setParticles !== undefined; +} + +export interface ParticleBatch { + reset(): void; + push(x: number, y: number, size: number, color: number): void; + flush(node: NodeMirror | undefined): void; +} + +/** Reusable packed particle buffer. Coordinates and size share storage with + * ABGR color words; PSP copies the batch into retained core capacity once + * per frame. */ +export function createParticleBatch(capacity: number): ParticleBatch { + const words = new Uint32Array(Math.max(1, capacity) * 4); + const floats = new Float32Array(words.buffer); + let count = 0; + return { + reset(): void { + count = 0; + }, + push(x: number, y: number, size: number, color: number): void { + if (count * 4 >= words.length) return; + const at = count * 4; + floats[at] = x; + floats[at + 1] = y; + floats[at + 2] = size; + words[at + 3] = color >>> 0; + count++; + }, + flush(node: NodeMirror | undefined): void { + if (!node) return; + getOps().setParticles?.(node.id, words, count); + }, + }; +} diff --git a/test/hot.test.ts b/test/hot.test.ts index 1029614..93eac9b 100644 --- a/test/hot.test.ts +++ b/test/hot.test.ts @@ -3,7 +3,7 @@ // for unchanged values and exactly one op per change. import { beforeEach, expect, test } from "bun:test"; import { installHost, type Host, type HostOps } from "../src/host.ts"; -import { createElement, resetRendererState, type NodeMirror } from "../src/native-tree.ts"; +import { createElement, registerTexture, resetRendererState, type NodeMirror } from "../src/native-tree.ts"; import * as hot from "../src/hot.ts"; let calls: [string, ...unknown[]][]; @@ -81,6 +81,55 @@ test("hot.prop encodes, gates, and rejects unknown props", () => { expect(() => hot.prop(el, "notAProp" as never, 1)).toThrow(/unknown style prop/); }); +test("hot.position falls back to two props and gates the coordinate pair", () => { + const el = createElement("view"); + hot.position(el, 12, -4); + expect(of("setProp")).toEqual([ + ["setProp", el.id, 128, 12], + ["setProp", el.id, 129, -4], + ]); + hot.position(el, 12, -4); + expect(of("setProp").length).toBe(2); +}); + +test("hot.position uses the fused host operation when available", () => { + const host = mockHost(); + host.ops.setTranslation = (...args) => calls.push(["setTranslation", ...args]); + installHost(host); + const el = createElement("view"); + hot.position(el, 7, 9); + expect(of("setTranslation")).toEqual([["setTranslation", el.id, 7, 9]]); + expect(of("setProp")).toEqual([]); +}); + +test("hot.image swaps textures once per changed key", () => { + registerTexture("wisp.png", 7); + registerTexture("kasa.png", 9); + const image = createElement("image"); + hot.image(image, "wisp.png"); + hot.image(image, "wisp.png"); + hot.image(image, "kasa.png"); + expect(of("setImage")).toEqual([ + ["setImage", image.id, 7], + ["setImage", image.id, 9], + ]); +}); + +test("particle batches pack f32 geometry and ABGR words into one host call", () => { + const host = mockHost(); + host.ops.setParticles = (id, words, count) => { + const floats = new Float32Array(words.buffer); + calls.push(["setParticles", id, count, floats[0], floats[1], floats[2], words[3]]); + }; + installHost(host); + expect(hot.supportsParticles()).toBe(true); + const layer = createElement("view"); + const batch = hot.createParticleBatch(2); + batch.push(3.5, -2, 8, 0xff112233); + batch.flush(layer); + expect(of("setParticles")).toEqual([["setParticles", layer.id, 1, 3.5, -2, 8, 0xff112233]]); +}); + test("hot.text on a bare text node works without a wrapper", () => { const run = createElement("text"); run.text = ""; diff --git a/test/nightbloom.sim.test.ts b/test/nightbloom.sim.test.ts index 1b36cb1..215da3a 100644 --- a/test/nightbloom.sim.test.ts +++ b/test/nightbloom.sim.test.ts @@ -8,8 +8,8 @@ // // Two tapes: // THE MARKSMAN — a full clear (~183 s): the catnip opens the night alone, -// its first ascension wakes the sakura, the midboss's fall -// rouses the gorilla, and the rotation walks all three +// the midboss's fall rouses the healing gorilla, and the +// rotation walks both forms // through the pilot seat to break the diva's last card. // THE SLEEPER — nobody home (~40 s): the lone catnip dies with no form // awake to switch to, and the night ends on the spot. @@ -42,7 +42,7 @@ const MARKSMAN_SECONDS = 190; // dawn settles at ~184.1 s const SLEEPER_SECONDS = 60; // the lone catnip falls at ~40 s // THE MARKSMAN — sweep-dodge and rotate. The switch presses no-op while a -// form is still locked, then pick each newcomer up as the night wakes it; +// form is still locked, then pick the gorilla up when the night wakes it; // over the night every form flies. const MARKSMAN = (() => { const T: { at: number; press?: number; hold?: number }[] = [{ at: 1.0, press: BTN.START }]; @@ -64,10 +64,10 @@ const MARKSMAN = (() => { T.push({ at: t + 3.5, press: BTN.TRIANGLE }); T.push({ at: t + 6, press: BTN.RTRIGGER }); } - // petal clears for the diva's dense cards + // moonrise boosts for the diva's dense cards for (const t of [139.0, 157.0]) { T.push({ at: t, press: BTN.RTRIGGER }); - T.push({ at: t + 1.5, press: BTN.TRIANGLE }); // PETALFALL + T.push({ at: t + 1.5, press: BTN.TRIANGLE }); // MOONRISE T.push({ at: t + 3.0, press: BTN.LTRIGGER }); } return T.sort((a, b) => a.at - b.at); @@ -163,15 +163,15 @@ describe("nightbloom: the night actually happened", () => { expect(treeHasText(t.tree, "DAWN BREAKS")).toBe(true); expect(treeHasText(t.tree, "THE DIVA FALLS SILENT")).toBe(true); // Act one: the score takes the stage (settled by the final frame). - expect(treeHasText(t.tree, "8575")).toBe(true); - expect(treeHasText(t.tree, "GRAZE: 92")).toBe(true); - expect(treeHasText(t.tree, "FOES FELLED: 33")).toBe(true); + expect(treeHasText(t.tree, "6830")).toBe(true); + expect(treeHasText(t.tree, "GRAZE: 56")).toBe(true); + expect(treeHasText(t.tree, "FOES FELLED: 20")).toBe(true); // The whole roster woke and every form survived to see the sun. expect(treeHasText(t.tree, "GREATEST BLOOM: STAGE 3")).toBe(true); - expect(treeHasText(t.tree, "SURVIVING FORMS: 3 OF 3 AWAKENED")).toBe(true); + expect(treeHasText(t.tree, "SURVIVING FORMS: 2 OF 2 AWAKENED")).toBe(true); // Act two: ONE medal, stamped on and congratulating the wrong thing. - expect(treeHasText(t.tree, "MERCY MEDAL")).toBe(true); - expect(treeHasText(t.tree, "2 FOES STROLLED OFF UNHARMED")).toBe(true); + expect(treeHasText(t.tree, "PINCUSHION")).toBe(true); + expect(treeHasText(t.tree, "STRUCK 21 TIMES AND PROUD")).toBe(true); } }); From 50a3586c5767b8ef4518b3ace6cc98b428c8425a Mon Sep 17 00:00:00 2001 From: lavs <79836722+SummonLav@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:44:48 +0800 Subject: [PATCH 4/5] perf(nightbloom): optimize combat rendering and unlocks Reduce Nightbloom PSP frame cost, move Moon Primrose to a 28-mote unlock, and render PixelLab cat-paw projectiles through one shared particle texture. --- core/src/draw.rs | 17 +- core/src/tests.rs | 23 + demos/nightbloom/README.md | 7 +- demos/nightbloom/app.tsx | 976 ++++++++++++++++++++++++++++++---- demos/nightbloom/data.ts | 13 +- demos/nightbloom/engine.ts | 343 ++++++++---- demos/nightbloom/shot-orb.png | Bin 269 -> 318 bytes native/build.rs | 5 + native/src/main.rs | 18 + scripts/bench-ppsspp.ts | 102 +++- src/hot.ts | 23 +- test/hot.test.ts | 23 + test/nightbloom.sim.test.ts | 10 +- 13 files changed, 1324 insertions(+), 236 deletions(-) diff --git a/core/src/draw.rs b/core/src/draw.rs index 511e6d4..60f2cdd 100644 --- a/core/src/draw.rs +++ b/core/src/draw.rs @@ -749,9 +749,24 @@ impl<'a> Walker<'a> { { continue; } + let (x, y) = world.apply(particle.x, particle.y); + if node.tex >= 0 { + self.emit_corner_quad( + dl, + node.tex as u32, + x, + y, + particle.size, + 0.0, + 0.0, + 1.0, + scale_alpha(particle.color, op), + &clip, + ); + continue; + } let r = roundf(particle.size * 0.5).max(1.0) as u32; if let Some((tex, dim)) = disc_texture(self.discs, self.textures, self.tex_free, r) { - let (x, y) = world.apply(particle.x, particle.y); let size = (r * 2) as f32; self.emit_corner_quad( dl, diff --git a/core/src/tests.rs b/core/src/tests.rs index a0df285..8c5e8da 100644 --- a/core/src/tests.rs +++ b/core/src/tests.rs @@ -280,6 +280,29 @@ fn particle_layer_reuses_one_tree_node_and_cleans_up_with_it() { assert_eq!(validate_drawlist(&ui.draw().words.clone())[spec::draw_op::RECT as usize], 0); } +#[test] +fn image_particle_layer_shares_its_bound_texture() { + let mut ui = Ui::new(); + let pixels = alloc::vec![0xffu8; 2 * 2 * 4]; + let tex = ui.upload_texture(&pixels, 2, 2, spec::psm::PSM_8888); + let layer = ui.create_node(spec::NodeType::Image as u8); + ui.set_prop(layer, spec::prop::WIDTH, 0.0); + ui.set_prop(layer, spec::prop::HEIGHT, 0.0); + ui.set_image(layer, tex); + ui.insert_before(spec::ROOT_ID, layer, 0); + ui.set_particles(layer, &[Particle { x: 4.0, y: 6.0, size: 10.0, color: 0xffffffff }]); + ui.tick(); + + let words = ui.draw().words.clone(); + assert_eq!(validate_drawlist(&words)[spec::draw_op::TEX_QUAD as usize], 1); + let i = words.iter().position(|&word| word == spec::draw_op::TEX_QUAD).unwrap(); + assert_eq!(words[i + 1], tex as u32); + assert_eq!(decode_xy(words[i + 2]), (4, 6)); + assert_eq!(decode_wh(words[i + 3]), (10, 10)); + assert_eq!(f32::from_bits(words[i + 4]), 0.0); + assert_eq!(f32::from_bits(words[i + 6]), 1.0); +} + #[test] fn arena_id_reuse_and_stale_id_noop() { let mut ui = Ui::new(); diff --git a/demos/nightbloom/README.md b/demos/nightbloom/README.md index cc8321e..4e384e8 100644 --- a/demos/nightbloom/README.md +++ b/demos/nightbloom/README.md @@ -43,7 +43,8 @@ that grows 52 -> 58 -> 66 px), announced by a flash ring and the boss's own cry — the diva chirps, the umbrella clangs. **The roster wakes as you play.** Only the CATNIP KIT answers at dusk — the -gorilla card shows `?` until the midboss falls and the mountain answers. +gorilla card shows a live moon-mote counter until 28 collected enemy drops +wake the mountain. Its reveal no longer shares the midboss-defeat frame. **And no pilot switches itself**: when the piloted form dies the LAST BREATH opens — a 1.5 s window to switch to a waking form (O / L / R). Miss it, or die with nobody else @@ -54,7 +55,7 @@ awake, and the night takes the run. | form | wakes | shot | the ability | | --- | --- | --- | --- | | CATNIP KIT | at dusk | homing orbs | a black-and-gold moon cat, a white moon waxing on its brow; **dances with death** — wider graze ring, double graze glow | -| MOON PRIMROSE | the midboss's fall | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, every damaging touch heals the most wounded form by 2, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | +| MOON PRIMROSE | collect 28 enemy-drop moon motes | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, every damaging touch heals the most wounded form by 2, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | Spell cards reinforce each role: NINE LIVES (homing burst and nearby clear), MOONRISE (heal 24 and +100 glow to the whole waking @@ -105,7 +106,7 @@ sink, and the simulation is byte-identical either way. `test/nightbloom.sim.test.ts` drives two tapes through the headless sim host: THE MARKSMAN, a full clear (~163 s — the catnip opens alone, the -midboss wakes the moon gorilla, and both forms see the dawn), +moon-mote gate wakes the gorilla, and both forms see the dawn), and THE SLEEPER, whose lone catnip falls at ~34 s with nobody awake to switch to. It asserts repeat-identity, chaos immunity, strict 4 Hz / 2 Hz subsampling of both tapes, cross-rate byte-equal outcome diff --git a/demos/nightbloom/app.tsx b/demos/nightbloom/app.tsx index 9a09eda..a9c686b 100644 --- a/demos/nightbloom/app.tsx +++ b/demos/nightbloom/app.tsx @@ -23,7 +23,7 @@ // gen-assets.ts. Every class is a FULL literal and all copy is ASCII (Inter // has no CJK). -import { For, Show, onCleanup } from "solid-js"; +import { For, Show, createEffect, onCleanup } from "solid-js"; import { Image, Screen, Text, View, type NodeMirror } from "@pocketjs/framework/components"; import * as hot from "@pocketjs/framework/hot"; import { onFrame } from "@pocketjs/framework/lifecycle"; @@ -45,10 +45,10 @@ import { FX_LIFE, MAX_ENEMY_SHOTS, MAX_MOTES, + PRIMROSE_UNLOCK_MOTES, STAMP_AT, STAMP_IMPACT, type EnemyShot, - type EnemyShotKind, type FloatFx, type FoeInst, type Nightbloom, @@ -224,15 +224,29 @@ function DeclarativeStarfield(props: { game: Nightbloom }) { function NativeStarfield(props: { game: Nightbloom }) { let layer: NodeMirror | undefined; + let lastTick = -2; const batch = hot.createParticleBatch(STARS.length); + const floats = batch.floats; + const words = batch.words; + for (let i = 0; i < STARS.length; i++) { + const at = i * 4; + floats[at + 2] = 4; + words[at + 3] = STARS[i].layer === 1 ? 0xff554133 : 0xff8b7464; + } const sync = () => { const tick = props.game.fxTick(); - batch.reset(); - for (const star of STARS) { - const y = (star.y + Math.floor(tick * (star.layer === 1 ? 0.35 : 0.7))) % FIELD.h; - batch.push(star.x, y, 4, star.layer === 1 ? 0xff554133 : 0xff8b7464); + // Stagger decorative/player batches opposite the enemy swarm so the + // interpreter never packs every layer on the same frame. + if (tick !== 0 && (tick & 1) === 0) return; + if (tick === lastTick) return; + lastTick = tick; + for (let i = 0; i < STARS.length; i++) { + const star = STARS[i]; + const at = i * 4; + floats[at] = star.x; + floats[at + 1] = (star.y + Math.floor(tick * (star.layer === 1 ? 0.35 : 0.7))) % FIELD.h; } - batch.flush(layer); + batch.flushCount(layer, STARS.length); }; onFrame(sync); return ( @@ -253,7 +267,10 @@ function Starfield(props: { game: Nightbloom }) { function PlayerNode(props: { game: Nightbloom }) { const g = props.game; + const native = hot.supportsParticles(); let playerNode: NodeMirror | undefined; + let playerImage: NodeMirror | undefined; + let nativeAvatarSize = AVATAR_SIZE[0]; const sprite = () => { const p = g.active(); return PLANTS[p.kind].sprites[p.stage() - 1]; @@ -268,13 +285,28 @@ function PlayerNode(props: { game: Nightbloom }) { }; /** Lean into the strafe, danmaku-style. */ const lean = () => g.lastDx() * 9; + const syncAppearance = () => { + if (!native) return; + const p = g.active(); + nativeAvatarSize = AVATAR_SIZE[p.stage() - 1]; + hot.prop(playerNode, "width", nativeAvatarSize); + hot.prop(playerNode, "height", nativeAvatarSize); + hot.prop(playerNode, "rotate", lean()); + hot.image(playerImage, PLANTS[p.kind].sprites[p.stage() - 1]); + hot.prop(playerImage, "scaleX", g.facing() * PLANTS[p.kind].artFacing); + }; + if (native) createEffect(syncAppearance); const syncPosition = () => { - const avatarSize = size(); + const avatarSize = native ? nativeAvatarSize : size(); hot.position( playerNode, g.px() - FIELD.x0 - avatarSize / 2, g.py() - FIELD.y0 - avatarSize / 2 + bob(), ); + // Mercy-invuln blink rides the same imperative sync: while invulnerable + // it flips every 4 ticks, and a Solid style binding would re-evaluate + // the whole style object per frame for the entire window. + hot.prop(playerNode, "opacity", blink() ? 1 : 0.35); }; onFrame(syncPosition); return ( @@ -283,16 +315,24 @@ function PlayerNode(props: { game: Nightbloom }) { class="absolute left-0 top-0 items-center justify-center" nodeRef={(node) => { playerNode = node; + syncAppearance(); syncPosition(); }} style={{ - width: size(), - height: size(), - rotate: lean(), - opacity: blink() ? 1 : 0.35, + width: native ? AVATAR_SIZE[0] : size(), + height: native ? AVATAR_SIZE[0] : size(), + rotate: native ? 0 : lean(), }} > - + { + playerImage = node; + syncAppearance(); + }} + style={{ scaleX: native ? 1 : g.facing() * PLANTS[g.active().kind].artFacing }} + /> @@ -377,6 +417,7 @@ interface FoeSlot { root?: NodeMirror; image?: NodeMirror; hp?: NodeMirror; + foeId?: number; } /** PSP keeps foe structure stable across waves. Spawning, death, and escape @@ -391,12 +432,18 @@ function NativeFoes(props: { game: Nightbloom; movers: MoverRegistry }) { const slot = slots[i]; const foe = foes[i]; const def = FOES[foe.kind]; - hot.image(slot.image, def.sprites[foe.stage - 1]); + if (slot.foeId !== foe.id) { + slot.foeId = foe.id; + hot.image(slot.image, def.sprites[foe.stage - 1]); + hot.prop(slot.root, "opacity", 1); + } hot.position(slot.root, foe.x - FIELD.x0 - 13, foe.y - FIELD.y0 - 13); hot.prop(slot.hp, "scaleX", Math.max(0, foe.hp() / def.hp[foe.stage - 1])); - hot.prop(slot.root, "opacity", 1); } - for (let i = nextVisible; i < visible; i++) hot.prop(slots[i].root, "opacity", 0); + for (let i = nextVisible; i < visible; i++) { + slots[i].foeId = undefined; + hot.prop(slots[i].root, "opacity", 0); + } visible = nextVisible; }; onFrame(sync); @@ -433,13 +480,15 @@ function Foes(props: { game: Nightbloom; movers: MoverRegistry }) { : {(foe) => }; } -function BossNode(props: { game: Nightbloom }) { +function DeclarativeBossNode(props: { game: Nightbloom }) { const g = props.game; return ( {(b) => { const phase = () => b.def.phases[b.phase()]; const size = () => phase().size; + const left = () => (g.fxTick(), b.x - FIELD.x0 - size() / 2); + const top = () => (g.fxTick(), b.y - FIELD.y0 - size() / 2); /** 0..1 metamorphosis progress (24 ticks), -1 when settled. */ const morph = () => { const at = g.bossFlash(); @@ -452,8 +501,8 @@ function BossNode(props: { game: Nightbloom }) { debugName="Boss" class="absolute items-center justify-center" style={{ - insetL: b.x() - FIELD.x0 - size() / 2, - insetT: b.y() - FIELD.y0 - size() / 2, + insetL: left(), + insetT: top(), width: size(), height: size(), scale: morph() >= 0 ? 1.45 - morph() * 0.45 : 1, @@ -480,6 +529,140 @@ function BossNode(props: { game: Nightbloom }) { ); } +const MAX_BOSS_SIZE = 66; + +/** PSP keeps one pre-bound boss node alive for the whole battle. Position, + * phase size, sprite, and the entry flash are paint-only updates. */ +function NativeBossNode(props: { game: Nightbloom }) { + const g = props.game; + let root: NodeMirror | undefined; + let image: NodeMirror | undefined; + let flash: NodeMirror | undefined; + let visible = false; + let sprite = ""; + let flashVisible = false; + const sync = () => { + const b = g.boss(); + if (!b) { + if (visible) { + visible = false; + hot.prop(root, "opacity", 0); + } + if (flashVisible) { + flashVisible = false; + hot.prop(flash, "opacity", 0); + } + return; + } + const phase = b.def.phases[b.phase()]; + const age = g.fxTick() - g.bossFlash(); + const morph = age >= 0 && age < 24 ? age / 24 : -1; + const pulse = morph >= 0 ? 1.45 - morph * 0.45 : 1; + if (sprite !== phase.sprite) { + sprite = phase.sprite; + hot.image(image, sprite); + } + hot.position(root, b.x - FIELD.x0 - MAX_BOSS_SIZE / 2, b.y - FIELD.y0 - MAX_BOSS_SIZE / 2); + hot.prop(root, "scale", phase.size / MAX_BOSS_SIZE * pulse); + if (!visible) { + visible = true; + hot.prop(root, "opacity", 1); + } + if (morph >= 0) { + flashVisible = true; + hot.prop(flash, "scale", (20 + morph * 70) / 80); + hot.prop(flash, "opacity", 1 - morph); + } else if (flashVisible) { + flashVisible = false; + hot.prop(flash, "opacity", 0); + } + }; + onFrame(sync); + return ( + { root = node; sync(); }} + style={{ opacity: 0 }} + > + { + image = node; + sprite = ""; + sync(); + }} + /> + (flash = node)} + style={{ insetL: -7, insetT: -7, opacity: 0 }} + /> + + ); +} + +function BossNode(props: { game: Nightbloom }) { + return hot.supportsParticles() ? : ; +} + +function DeclarativeBossHealth(props: { game: Nightbloom }) { + return ( + + {(b) => ( + + + + + + )} + + ); +} + +function NativeBossHealth(props: { game: Nightbloom }) { + let root: NodeMirror | undefined; + let fill: NodeMirror | undefined; + let visible = false; + const sync = () => { + const b = props.game.boss(); + if (!b) { + if (visible) { + visible = false; + hot.prop(root, "opacity", 0); + } + return; + } + hot.prop(fill, "scaleX", Math.max(0, b.hp / b.def.phases[b.phase()].hp)); + if (!visible) { + visible = true; + hot.prop(root, "opacity", 1); + } + }; + onFrame(sync); + return ( + { root = node; sync(); }} + style={{ opacity: 0 }} + > + + (fill = node)} /> + + + ); +} + +function BossHealth(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + function EnemyShotNode(props: { shot: EnemyShot; movers: MoverRegistry }) { const s = props.shot; return s.kind === "mochi" ? ( @@ -504,29 +687,39 @@ function EnemyShotNode(props: { shot: EnemyShot; movers: MoverRegistry }) { ); } -/** ABGR colors matching the fallback Tailwind fills. Mochi becomes a native - * white dot on PSP so every trajectory fits one compact RECT batch. */ -const ENEMY_SHOT_COLORS: Record = { - pink: 0xffd4a8f9, - cyan: 0xfff9e867, - amber: 0xff4dd3fc, - mochi: 0xffffffff, -}; - /** PSP: one retained node + one packed host call per frame. Other hosts keep * the declarative nodes so browser rendering and deterministic tests need no - * new host capability. */ + * new host capability. The fill loop writes the packed batch directly — + * at 48 bullets a push() closure call per particle is measurable QuickJS + * time on the hottest frames the game produces. */ function NativeEnemyShotLayer(props: { game: Nightbloom }) { let layer: NodeMirror | undefined; const batch = hot.createParticleBatch(MAX_ENEMY_SHOTS); + const floats = batch.floats; + const words = batch.words; + const offX = FIELD.x0 + 4; + const offY = FIELD.y0 + 4; + for (let i = 0; i < batch.capacity; i++) floats[i * 4 + 2] = 8; + let lastTick = -1; const sync = () => { + const tick = props.game.fxTick(); + // The slowest boss bullets move < 1 px per simulation tick. Repainting + // their retained batch at 30 Hz halves typed-array traffic while keeping + // collision and simulation on the exact 60 Hz grid. + if (tick !== 0 && (tick & 1) !== 0) return; + if (tick === lastTick) return; + lastTick = tick; const shots = props.game.enemyShots(); - batch.reset(); - for (let i = 0; i < shots.length; i++) { + let n = shots.length; + if (n > batch.capacity) n = batch.capacity; + for (let i = 0; i < n; i++) { const shot = shots[i]; - batch.push(shot.x - FIELD.x0 - 4, shot.y - FIELD.y0 - 4, 8, ENEMY_SHOT_COLORS[shot.kind]); + const at = i * 4; + floats[at] = shot.x - offX; + floats[at + 1] = shot.y - offY; + words[at + 3] = shot.color; } - batch.flush(layer); + batch.flushCount(layer, n); }; onFrame(sync); return ( @@ -570,22 +763,43 @@ function PlayerShotNode(props: { shot: PlayerShot; movers: MoverRegistry }) { function NativePlayerShotLayer(props: { game: Nightbloom }) { let layer: NodeMirror | undefined; - const batch = hot.createParticleBatch(40); + const batch = hot.createParticleBatch(28); + const floats = batch.floats; + const words = batch.words; + const offX = FIELD.x0 + 5; + const offY = FIELD.y0 + 5; + for (let i = 0; i < batch.capacity; i++) { + const at = i * 4; + // The Image node's PixelLab texture is shared by the whole particle + // batch. White keeps its authored mint/lime palette intact; there is + // still only one retained node and one packed host call per repaint. + floats[at + 2] = 10; + words[at + 3] = 0xffffffff; + } + let lastTick = -1; const sync = () => { + const tick = props.game.fxTick(); + if (tick !== 0 && (tick & 1) === 0) return; + if (tick === lastTick) return; + lastTick = tick; const shots = props.game.playerShots(); - batch.reset(); - for (let i = 0; i < shots.length; i++) { + let n = 0; + for (let i = 0; i < shots.length && n < batch.capacity; i++) { const shot = shots[i]; if (shot.kind === "banana") continue; - batch.push(shot.x - FIELD.x0 - 6, shot.y - FIELD.y0 - 6, 12, 0xffd4a8f9); + const at = n * 4; + floats[at] = shot.x - offX; + floats[at + 1] = shot.y - offY; + n++; } - batch.flush(layer); + batch.flushCount(layer, n); }; onFrame(sync); return ( - { layer = node; sync(); @@ -594,6 +808,42 @@ function NativePlayerShotLayer(props: { game: Nightbloom }) { ); } +const BANANA_POOL = [0, 1, 2] as const; + +function NativeBananas(props: { game: Nightbloom }) { + const nodes: Array = []; + let visible = 0; + const sync = () => { + const shots = props.game.playerShots(); + let nextVisible = 0; + const spin = props.game.fxTick() * 9; + for (let i = 0; i < shots.length && nextVisible < BANANA_POOL.length; i++) { + const shot = shots[i]; + if (shot.kind !== "banana") continue; + const node = nodes[nextVisible]; + hot.position(node, shot.x - FIELD.x0 - 7, shot.y - FIELD.y0 - 7); + hot.prop(node, "rotate", (spin + shot.id * 40) % 360); + hot.prop(node, "opacity", 1); + nextVisible++; + } + for (let i = nextVisible; i < visible; i++) hot.prop(nodes[i], "opacity", 0); + visible = nextVisible; + }; + onFrame(sync); + return ( + + {(i) => ( + { nodes[i] = node; sync(); }} + style={{ opacity: 0 }} + /> + )} + + ); +} + function PlayerShots(props: { game: Nightbloom; movers: MoverRegistry }) { if (!hot.supportsParticles()) { return {(shot) => }; @@ -601,9 +851,7 @@ function PlayerShots(props: { game: Nightbloom; movers: MoverRegistry }) { return ( <> - shot.kind === "banana")}> - {(shot) => } - + ); } @@ -622,14 +870,31 @@ function MoteNode(props: { mote: MovingEntity; movers: MoverRegistry }) { function NativeMotes(props: { game: Nightbloom }) { let layer: NodeMirror | undefined; const batch = hot.createParticleBatch(MAX_MOTES); + const floats = batch.floats; + const words = batch.words; + const offX = FIELD.x0 + 3; + const offY = FIELD.y0 + 3; + for (let i = 0; i < batch.capacity; i++) { + const at = i * 4; + floats[at + 2] = 6; + words[at + 3] = 0xff7dd3fc; + } + let lastTick = -1; const sync = () => { + const tick = props.game.fxTick(); + if (tick !== 0 && (tick & 1) === 0) return; + if (tick === lastTick) return; + lastTick = tick; const motes = props.game.motes(); - batch.reset(); - for (let i = 0; i < motes.length; i++) { + let n = motes.length; + if (n > batch.capacity) n = batch.capacity; + for (let i = 0; i < n; i++) { const mote = motes[i]; - batch.push(mote.x - FIELD.x0 - 3, mote.y - FIELD.y0 - 3, 6, 0xff7dd3fc); + const at = i * 4; + floats[at] = mote.x - offX; + floats[at + 1] = mote.y - offY; } - batch.flush(layer); + batch.flushCount(layer, n); }; onFrame(sync); return { layer = node; sync(); }} />; @@ -659,6 +924,79 @@ function FxNode(props: { game: Nightbloom; fx: FloatFx }) { ); } +const FX_POOL = [0, 1, 2, 3] as const; + +/** ABGR tone colors matching the declarative classes (amber/cyan/pink/red 300). */ +const FX_TONE_COLORS: Record = { + lumen: 0xff4dd3fc, + ward: 0xfff9e867, + evolve: 0xffd4a8f9, + hurt: 0xffa5a5fc, +}; + +interface FxSlot { + root?: NodeMirror; + text?: NodeMirror; + fxId?: number; +} + +/** PSP: float fx ("-13", "UP!") repaint four pre-mounted fixed-cell slots. + * Mounting one through Solid costs a structural frame AND its text shape + * costs a relayout — the frame trace shows each player hit as TWO ~85 ms + * frames (fx mount, then its unmount 0.9 s later) without this pool. */ +function NativeFxLayer(props: { game: Nightbloom }) { + const g = props.game; + const slots: FxSlot[] = FX_POOL.map(() => ({})); + let visible = 0; + const sync = () => { + const fxs = g.fxs(); + const n = Math.min(fxs.length, slots.length); + if (n === 0 && visible === 0) return; + const tick = g.fxTick(); + for (let i = 0; i < n; i++) { + const fx = fxs[i]; + const slot = slots[i]; + const age = Math.min(1, Math.max(0, (tick - fx.born) / FX_LIFE)); + if (slot.fxId !== fx.id) { + slot.fxId = fx.id; + hot.text(slot.text, fx.text); + hot.prop(slot.text, "textColor", FX_TONE_COLORS[fx.tone]); + } + hot.position(slot.root, fx.x - FIELD.x0 - 24, fx.y - FIELD.y0 - 12 * age); + hot.prop(slot.root, "opacity", 1 - age); + } + for (let i = n; i < visible; i++) { + slots[i].fxId = undefined; + hot.prop(slots[i].root, "opacity", 0); + } + visible = n; + }; + onFrame(sync); + return ( + + {(i) => ( + (slots[i].root = node)} + style={{ opacity: 0, width: 48, height: 14 }} + > + (slots[i].text = node)} + style={{ width: 48, height: 14 }} + >- + + )} + + ); +} + +function FxLayer(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : {(f) => }; +} + function Field(props: { game: Nightbloom }) { const g = props.game; const movers: MoverRegistry = []; @@ -689,7 +1027,7 @@ function Field(props: { game: Nightbloom }) { - {(f) => } + {"SWITCH NOW O / L / R " + g.wiltSeconds() + "s"} - - {(b) => ( - - - - - - )} - + ); } @@ -725,14 +1052,19 @@ function Field(props: { game: Nightbloom }) { * still. Width is estimated from the glyph count. */ function Marquee(props: { game: Nightbloom; text: string; cls: string; width: number }) { const textW = () => props.text.length * 7; - const scroll = () => { - if (textW() <= props.width) return 0; + let track: NodeMirror | undefined; + const sync = () => { + if (textW() <= props.width) { + hot.position(track, 0, 0); + return; + } const span = textW() + 24; - return -((props.game.fxTick() * 0.6) % span); + hot.position(track, -((props.game.fxTick() * 0.6) % span), 0); }; + onFrame(sync); return ( - + { track = node; sync(); }}> {props.text} props.width}> {props.text} @@ -742,6 +1074,138 @@ function Marquee(props: { game: Nightbloom; text: string; cls: string; width: nu ); } +function DeclarativeBossPanel(props: { game: Nightbloom }) { + const g = props.game; + return ( + + {(b) => ( + + + + {"TIMEOUT " + g.bossCardSeconds() + "s"} + + )} + + ); +} + +function NativeBossPanel(props: { game: Nightbloom }) { + const g = props.game; + let root: NodeMirror | undefined; + let nameTrack: NodeMirror | undefined; + let nameText: NodeMirror | undefined; + let cardTrack: NodeMirror | undefined; + let cardText: NodeMirror | undefined; + let timeoutText: NodeMirror | undefined; + const ticker = (track: NodeMirror | undefined, width: number) => { + hot.position(track, width <= 102 ? 0 : -((g.fxTick() * 0.6) % (width + 24)), 0); + }; + let lastTimeout = -1; + let lastName = ""; + let lastCard = ""; + let nameWidth = 0; + let cardWidth = 0; + let visible = false; + const sync = () => { + const b = g.boss(); + if (!b) { + if (visible) { + visible = false; + hot.prop(root, "opacity", 0); + } + return; + } + const name = b.def.name; + const card = b.def.phases[b.phase()].card; + if (name !== lastName) { + lastName = name; + nameWidth = name.length * 7; + hot.text(nameText, name); + } + if (card !== lastCard) { + lastCard = card; + cardWidth = card.length * 7; + hot.text(cardText, card); + } + // Gate on the integer BEFORE building the string: hot.text's own gate + // would still cook a fresh template literal every frame. + const timeoutS = g.bossCardSeconds(); + if (timeoutS !== lastTimeout) { + lastTimeout = timeoutS; + hot.text(timeoutText, `TIMEOUT ${timeoutS}s`); + } + if ((g.fxTick() & 1) === 0) { + ticker(nameTrack, nameWidth); + ticker(cardTrack, cardWidth); + } + if (!visible) { + visible = true; + hot.prop(root, "opacity", 1); + } + }; + onFrame(sync); + return ( + { root = node; sync(); }} + style={{ height: 62, opacity: 0 }} + > + + (nameTrack = node)} style={{ width: 200, height: 16 }}> + (nameText = node)} style={{ width: 200, height: 16 }}>BOSS + + + + (cardTrack = node)} style={{ width: 200, height: 16 }}> + (cardText = node)} style={{ width: 200, height: 16 }}>SPELL CARD + + + (timeoutText = node)} style={{ width: 102, height: 16 }}>TIMEOUT 0s + + ); +} + +function BossPanel(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + +function DeclarativeNightClock(props: { game: Nightbloom }) { + return {String(props.game.second()) + "s TO DAWN?"}; +} + +/** A fixed native cell keeps the once-per-second clock repaint out of layout. + * Without it each second boundary adds a repeatable 3.5 ms core-tick spike. */ +function NativeNightClock(props: { game: Nightbloom }) { + let node: NodeMirror | undefined; + let lastSecond = -1; + const sync = () => { + const second = props.game.second(); + if (second === lastSecond) return; + lastSecond = second; + hot.text(node, `${second}s TO DAWN?`); + }; + onFrame(sync); + return ( + { + node = next; + lastSecond = -1; + sync(); + }} + style={{ width: 102, height: 16 }} + >0s TO DAWN? + ); +} + +function NightClock(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + function LeftPanel(props: { game: Nightbloom }) { const g = props.game; const phaseName = () => { @@ -760,7 +1224,7 @@ function LeftPanel(props: { game: Nightbloom }) { {phaseName()} {"WAVE " + g.waveIdx() + "/" + WAVES.length} - {String(g.second()) + "s TO DAWN?"} + @@ -768,15 +1232,7 @@ function LeftPanel(props: { game: Nightbloom }) { - - {(b) => ( - - - - {"TIMEOUT " + g.bossCardSeconds() + "s"} - - )} - + HOLD X FIRE [] FOCUS {"O / L / R SWITCH /\\ SPELL"} @@ -804,9 +1260,15 @@ function RosterCard(props: { game: Nightbloom; idx: number; plant: PlantState }) const g = props.game; const p = props.plant; const def = PLANTS[p.kind]; + const native = hot.supportsParticles(); + let root: NodeMirror | undefined; + let hpFill: NodeMirror | undefined; + let lockedOverlay: NodeMirror | undefined; + let lockedProgress: NodeMirror | undefined; const isActive = () => g.activeIdx() === props.idx; const wilted = () => p.hp() <= 0; const cardClass = () => { + if (native) return "relative flex-row items-center gap-1 p-1 rounded-md border border-slate-700 bg-slate-900 overflow-hidden"; if (wilted()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-slate-800 bg-slate-900 opacity-40 overflow-hidden"; if (isActive() && g.wilting()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-red-400 bg-slate-800 overflow-hidden"; if (isActive()) return "relative flex-row items-center gap-1 p-1 rounded-md border border-amber-300 bg-slate-800 overflow-hidden"; @@ -819,9 +1281,35 @@ function RosterCard(props: { game: Nightbloom; idx: number; plant: PlantState }) const age = g.fxTick() - at; return age >= 0 && age < 48 ? age / 48 : -1; }; + let lastState = ""; + let lastHp = -1; + const syncNativeRoster = () => { + if (native) { + const hp = p.hp(); + const active = isActive(); + const unlocked = p.unlocked(); + const state = !unlocked ? "locked" : hp <= 0 ? "wilted" : active && g.wilting() ? "danger" : active ? "active" : "idle"; + if (state !== lastState) { + lastState = state; + hot.prop(root, "opacity", state === "wilted" ? 0.4 : 1); + hot.prop(root, "borderColor", state === "danger" ? 0xff7171f8 : state === "active" ? 0xff4dd3fc : 0xff554133); + hot.prop(root, "bgColor", state === "danger" || state === "active" ? 0xff3b291e : 0xff2a170f); + hot.prop(lockedOverlay, "opacity", unlocked ? 0 : 1); + } + if (!unlocked) { + hot.text(lockedProgress, `MOTES ${g.motesCollected()}/${PRIMROSE_UNLOCK_MOTES}`); + } + const scale = Math.max(0, hp / def.hp[p.stage() - 1]); + if (scale !== lastHp) { + lastHp = scale; + hot.prop(hpFill, "scaleX", scale); + } + } + }; + if (native) createEffect(syncNativeRoster); return ( @@ -834,8 +1322,15 @@ function RosterCard(props: { game: Nightbloom; idx: number; plant: PlantState }) } > - - = 0}> + { + root = node; + lastState = ""; + syncNativeRoster(); + }} + > + = 0}> {/* the slanted shine head */} - + {native ? ( + { + hpFill = node; + lastHp = -1; + syncNativeRoster(); + }} + /> + ) : ( + + )} + {native && p.kind === "primrose" && ( + { + lockedOverlay = node; + lockedProgress = node; + lastState = ""; + syncNativeRoster(); + }} + style={{ height: 28, paddingT: 7 }} + >MOTES 0/28 + )} ); } -function RightPanel(props: { game: Nightbloom }) { +/** Score + graze counters. On PSP these change on nearly every boss-window + * frame (graze pays +10 a bullet), and a Solid text binding per change costs + * effect + relayout; fixed-size right-aligned cells + hot.text keep every + * update paint-only. Other hosts keep the declarative texts. */ +function DeclarativeScoreboard(props: { game: Nightbloom }) { const g = props.game; return ( - + <> SCORE {String(g.score())} @@ -899,39 +1417,217 @@ function RightPanel(props: { game: Nightbloom }) { GRAZE {String(g.graze())} + + ); +} + +function NativeScoreboard(props: { game: Nightbloom }) { + let scoreText: NodeMirror | undefined; + let grazeText: NodeMirror | undefined; + let lastScore = -1; + let lastGraze = -1; + const sync = () => { + const score = props.game.score(); + const graze = props.game.graze(); + if (score !== lastScore) { + lastScore = score; + hot.text(scoreText, score); + } + if (graze !== lastGraze) { + lastGraze = graze; + hot.text(grazeText, graze); + } + }; + onFrame(sync); + return ( + <> + + SCORE + { scoreText = node; sync(); }} + style={{ width: 66, height: 18 }} + >0 + + + GRAZE + (grazeText = node)} + style={{ width: 48, height: 16 }} + >0 + + + ); +} + +function Scoreboard(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + +/** The spell-card box. spellReady ticks EVERY battle tick while a cooldown + * drains; the native variant repaints the arc through hot.prop quantized to + * 45 steps (one paint-only op every ~half second) instead of a per-frame + * Solid style re-evaluation. */ +function DeclarativeSpellBox(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + + {PLANTS[g.active().kind].spell.name} + {g.active().spellReady() >= 1 ? "READY" : "CHARGING"} + + + ); +} + +function NativeSpellBox(props: { game: Nightbloom }) { + const g = props.game; + let arc: NodeMirror | undefined; + let stateText: NodeMirror | undefined; + let lastSweep = -1; + let lastReady: boolean | undefined; + const sync = () => { + const ready = g.active().spellReady(); + const sweep = ready >= 1 ? 360 : Math.max(8, Math.round(ready * 45) * 8); + const isReady = ready >= 1; + if (sweep !== lastSweep) { + lastSweep = sweep; + hot.prop(arc, "arcSweep", sweep); + } + if (isReady !== lastReady) { + lastReady = isReady; + hot.text(stateText, isReady ? "READY" : "CHARGING"); + } + }; + onFrame(sync); + return ( + + { arc = node; sync(); }} + style={{ arcStart: 0, arcSweep: 360, arcWidth: 2 }} + /> + + {PLANTS[g.active().kind].spell.name} + (stateText = node)} + style={{ width: 66, height: 16 }} + >READY + + + ); +} + +function SpellBox(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + +function RightPanel(props: { game: Nightbloom }) { + const g = props.game; + return ( + + {(p, i) => } - - - BANANAS - - {(i) => ( - sh.kind === "banana").length > i ? 0.25 : 1 }} - /> - )} - - - - - - - {PLANTS[g.active().kind].spell.name} - {g.active().spellReady() >= 1 ? "READY" : "CHARGING"} - + + + + ); +} + +function DeclarativeBananaBadge(props: { game: Nightbloom }) { + const g = props.game; + return ( + + + BANANAS + + {(i) => ( + sh.kind === "banana").length > i ? 0.25 : 1 }} + /> + )} + + + ); +} + +/** PSP: the badge stays mounted and toggles opacity — a form switch must not + * pay a subtree mount (the structural-frame hitch) for a HUD ornament. The + * hand count repaints through the same sync. */ +function NativeBananaBadge(props: { game: Nightbloom }) { + const g = props.game; + let root: NodeMirror | undefined; + const icons: Array = []; + let visible = false; + let lastAloft = -1; + const sync = () => { + if (g.active().kind !== "primrose") { + if (visible) { + visible = false; + hot.prop(root, "opacity", 0); + } + return; + } + if (!visible) { + visible = true; + hot.prop(root, "opacity", 1); + } + const shots = g.playerShots(); + let aloft = 0; + for (let i = 0; i < shots.length; i++) if (shots[i].kind === "banana") aloft++; + if (aloft !== lastAloft) { + lastAloft = aloft; + for (let i = 0; i < 3; i++) hot.prop(icons[i], "opacity", aloft > i ? 0.25 : 1); + } + }; + onFrame(sync); + return ( + { root = node; sync(); }} + style={{ opacity: 0 }} + > + BANANAS + + {(i) => ( + (icons[i] = node)} + /> + )} + ); } -function ToastStack(props: { game: Nightbloom }) { +function BananaBadge(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + +function DeclarativeToastStack(props: { game: Nightbloom }) { return ( @@ -945,6 +1641,64 @@ function ToastStack(props: { game: Nightbloom }) { ); } +const TOAST_POOL = [0, 1, 2] as const; + +interface ToastSlot { + root?: NodeMirror; + text?: NodeMirror; +} + +/** PSP: toasts land in three pre-mounted slots on a fixed 26 px pitch, with + * FIXED-SIZE text cells. Both halves matter: mounting a toast box through + * Solid is a structural frame, and swapping text in an auto-sized cell + * dirties layout — the frame trace shows that relayout as a ~50 ms core + * tick on hardware, fired by the player's core verbs (switch, spell). */ +function NativeToastStack(props: { game: Nightbloom }) { + const slots: ToastSlot[] = TOAST_POOL.map(() => ({})); + let visible = 0; + let lastToasts: readonly unknown[] | undefined; + const sync = () => { + const toasts = props.game.toasts(); + if (toasts === lastToasts) return; + lastToasts = toasts; + const n = Math.min(toasts.length, slots.length); + for (let i = 0; i < n; i++) { + hot.text(slots[i].text, toasts[i].text); + hot.prop(slots[i].root, "opacity", 1); + } + for (let i = n; i < visible; i++) hot.prop(slots[i].root, "opacity", 0); + visible = n; + }; + onFrame(sync); + return ( + + + {(i) => ( + + (slots[i].root = node)} + style={{ opacity: 0 }} + > + (slots[i].text = node)} + style={{ width: 184, height: 16 }} + >{" "} + + + )} + + + ); +} + +function ToastStack(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + // --------------------------------------------------------------------------- // Codex — the almanac of laws (SELECT) // --------------------------------------------------------------------------- diff --git a/demos/nightbloom/data.ts b/demos/nightbloom/data.ts index 0c68a60..f1bd288 100644 --- a/demos/nightbloom/data.ts +++ b/demos/nightbloom/data.ts @@ -137,9 +137,9 @@ export const PLANTS: Record = { armor: [0, 0, 0], speed: 120, artFacing: 1, - dmg: [7, 8, 10], + dmg: [7, 16, 20], period: [0.34, 0.3, 0.26], - streams: [1, 2, 2], + streams: [1, 1, 1], sprites: ["p-catnip-1.png", "p-catnip-2.png", "p-catnip-3.png"], evolveAt: [420, 1400], law: "HOMING ORBS, AND IT DANCES WITH DEATH: WIDER GRAZE, DOUBLE GLOW", @@ -513,7 +513,14 @@ export const ART: ArtEntry[] = [ "the same fluffy night sparrow as a radiant little diva, soft plume crown, spiral of glowing song light, sweet face", ], 2040), // --- projectiles + mote (32x32, transparent, drawn at 16px) --------------- - { name: "shot-orb.png", prompt: `round pink energy orb with a tiny paw print, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3002, transparent: true }, + { + name: "shot-orb.png", + prompt: `bright emerald cat-paw comet projectile, four tiny lime toe sparks around a mint crescent core, friendly player attack, unmistakable cat magic, no pink, no red, ${SHOT_STYLE}`, + w: SHOT, + h: SHOT, + seed: 3012, + transparent: true, + }, { name: "shot-mochi.png", prompt: `small round white mochi rice cake, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3003, transparent: true }, { name: "mote.png", prompt: `small silver-blue moonlight droplet, sparkling, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3004, transparent: true }, { name: "shot-banana.png", prompt: `curved ripe yellow banana, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3005, transparent: true }, diff --git a/demos/nightbloom/engine.ts b/demos/nightbloom/engine.ts index cd7d206..11a5f6f 100644 --- a/demos/nightbloom/engine.ts +++ b/demos/nightbloom/engine.ts @@ -91,11 +91,22 @@ function sfx(kind: SfxKind): void { /** 64-step sine table quantized to 1/8192: far coarser than any engine's * last-ulp sin() divergence, so the values are cross-engine constants. */ const SIN: number[] = Array.from({ length: 64 }, (_, i) => Math.round(Math.sin((i / 64) * Math.PI * 2) * 8192) / 8192); -const sinA = (a: number): number => SIN[((a % 64) + 64) % 64]; -const cosA = (a: number): number => SIN[(((a + 16) % 64) + 64) % 64]; +// Every caller supplies an integer table index. A bit mask handles positive +// and negative angles alike and is much cheaper than two `%` operations in +// QuickJS's interpreted boss-volley loops. +const sinA = (a: number): number => SIN[a & 63]; +const cosA = (a: number): number => SIN[(a + 16) & 63]; /** Angle index pointing straight down (+y). 0 = +x, quarter turn = 16. */ const A_DOWN = 16; +// Field bounds as module consts: the per-tick loops below read them per +// bullet/foe/mote, and a QuickJS property chain (FIELD.x0) costs several +// times a captured const on the PSP interpreter. +const FX0 = FIELD.x0; +const FY0 = FIELD.y0; +const FW = FIELD.w; +const FH = FIELD.h; + // --------------------------------------------------------------------------- // Reactive cells — a signal dressed as a readable-callable with .set() // --------------------------------------------------------------------------- @@ -157,9 +168,10 @@ export interface BossInst { /** True for the midboss (waves resume after it breaks). */ mid: boolean; phase: Cell; - hp: Cell; - x: Cell; - y: Cell; + /** Plain hot fields; renderers redraw them from the shared frame tick. */ + hp: number; + x: number; + y: number; timeoutTicks: number; fireCd: number; fireCd2: number; @@ -173,6 +185,9 @@ export type EnemyShotKind = "pink" | "cyan" | "amber" | "mochi"; export interface EnemyShot { id: number; kind: EnemyShotKind; + /** Cached ABGR presentation color. Avoids a string-keyed table lookup for + * every visible bullet on every PSP frame. */ + color: number; x: number; y: number; /** Per-tick motion; enemy trajectories never steer after spawn. */ @@ -184,6 +199,13 @@ export interface EnemyShot { dead?: boolean; } +const ENEMY_SHOT_COLOR: Record = { + pink: 0xffd4a8f9, + cyan: 0xfff9e867, + amber: 0xff4dd3fc, + mochi: 0xffffffff, +}; + export type PlayerShotKind = "orb" | "banana"; export interface PlayerShot { @@ -199,6 +221,10 @@ export interface PlayerShot { /** How many more bodies a bolt may pass through. */ through: number; homing: boolean; + /** Steering is sampled at 30 Hz while motion remains on the 60 Hz grid. */ + homeCd?: number; + /** Homing preserves speed, so its magnitude is immutable after spawn. */ + homeSpeed?: number; /** Banana boomerang: true once it has turned and is flying home. */ ret: boolean; /** Banana boomerang: ticks until it may damage again (it never despawns @@ -251,6 +277,8 @@ export interface Nightbloom { /** The roast ledger: what the dawn medals tease you about. */ escaped: Accessor; hitsTaken: Accessor; + /** Enemy-drop motes actually collected this run. */ + motesCollected: Accessor; motesMissed: Accessor; cardTimeouts: Accessor; px: Accessor; @@ -297,8 +325,12 @@ const STATION_TICKS = 8 * TPS; /** The world scrolls on beneath everyone: even a hovering foe sinks with it, * so an unkilled monster always leaves the field eventually. */ const WORLD_DRIFT = 10 / TPS; -const MAX_PLAYER_SHOTS = 40; +const MAX_PLAYER_SHOTS = 28; export const MAX_MOTES = 24; +/** The moon primrose wakes from player-earned enemy drops, not a scripted + * boss beat. Twenty-eight motes rewards an active pilot before the midboss + * without coupling the unlock to the boss's resource-heavy entrance. */ +export const PRIMROSE_UNLOCK_MOTES = 28; const SWITCH_TICKS = Math.round(SWITCH_COOLDOWN * TPS); const HURT_TICKS = Math.round(HURT_INVULN * TPS); @@ -373,6 +405,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { let rescues = 0; const escaped = cell(0); const hitsTaken = cell(0); + const motesCollected = cell(0); const motesMissed = cell(0); const cardTimeouts = cell(0); const wilting = cell(false); @@ -425,6 +458,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { tick = 0; rng = NIGHT_SEED >>> 0; pendingEnemyFire = []; + enemyShotCount = 0; nextWave = 0; nextPhase = 0; fireCd = 0; @@ -462,6 +496,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { rescues = 0; escaped.set(0); hitsTaken.set(0); + motesCollected.set(0); motesMissed.set(0); cardTimeouts.set(0); wilting.set(false); @@ -535,9 +570,9 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { def, mid, phase: cell(0), - hp: cell(def.phases[0].hp), - x: cell(FIELD.x0 + FIELD.w / 2), - y: cell(FIELD.y0 + 46), + hp: def.phases[0].hp, + x: FIELD.x0 + FIELD.w / 2, + y: FIELD.y0 + 46, timeoutTicks: def.phases[0].timeout * TPS, fireCd: TPS, fireCd2: 2 * TPS, @@ -555,38 +590,55 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { // and tickShots flushes it in ONE array set — same tick, same cap, same // order as the old per-bullet sets, minus the per-bullet array copies. let pendingEnemyFire: EnemyShot[] = []; + let enemyShotCount = 0; function enemyFire(x: number, y: number, vx: number, vy: number, kind: EnemyShotKind, dmg: number): void { - if (enemyShots().length + pendingEnemyFire.length >= MAX_ENEMY_SHOTS) return; + if (enemyShotCount + pendingEnemyFire.length >= MAX_ENEMY_SHOTS) return; // Enemy velocity never changes after spawn. Store the per-tick delta once // instead of dividing twice per bullet on every interpreted frame. - pendingEnemyFire.push({ id: ++idSeq, kind, x, y, vx: vx / TPS, vy: vy / TPS, dmg, grazed: false }); + // `dead` is initialized here so every bullet keeps ONE QuickJS shape — + // adding the field later on despawn transitions the shape and defeats + // the interpreter's inline caches in the per-tick loops. + pendingEnemyFire.push({ + id: ++idSeq, kind, color: ENEMY_SHOT_COLOR[kind], x, y, + vx: vx / TPS, vy: vy / TPS, dmg, grazed: false, dead: false, + }); } function flushEnemyFire(): void { if (pendingEnemyFire.length === 0) return; if (paintOnlyShots) { const shots = enemyShots(); - for (const shot of pendingEnemyFire) shots.push(shot); + for (let i = 0; i < pendingEnemyFire.length; i++) shots.push(pendingEnemyFire[i]); + enemyShotCount = shots.length; pendingEnemyFire.length = 0; } else { - enemyShots.set([...enemyShots(), ...pendingEnemyFire]); + const next = [...enemyShots(), ...pendingEnemyFire]; + enemyShotCount = next.length; + enemyShots.set(next); pendingEnemyFire = []; } } + /** Scratch return cell — every caller reads it before the next aimedAt, + * so one reused object replaces a per-volley allocation (QuickJS garbage + * is what schedules the ~100 ms full-GC hitches on hardware). */ + const aim = { vx: 0, vy: 0 }; + function aimedAt(x: number, y: number, speed: number): { vx: number; vy: number } { const dx = px() - x; const dy = py() - y; const len = Math.sqrt(dx * dx + dy * dy) || 1; - return { vx: (dx / len) * speed, vy: (dy / len) * speed }; + aim.vx = (dx / len) * speed; + aim.vy = (dy / len) * speed; + return aim; } function dropMotes(x: number, y: number, count: number): void { const add: MoteInst[] = []; for (let i = 0; i < count; i++) { if (motes().length + add.length >= MAX_MOTES) break; - add.push({ id: ++idSeq, x: x + rnd(17) - 8, y: y + rnd(9) - 4 }); + add.push({ id: ++idSeq, x: x + rnd(17) - 8, y: y + rnd(9) - 4, dead: false }); } if (add.length > 0) motes.set([...motes(), ...add]); } @@ -599,7 +651,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (roster[owner]?.kind !== "primrose") return; let target: PlantState | null = null; let worst = 1; - for (const r of roster) { + for (let i = 0; i < roster.length; i++) { + const r = roster[i]; if (!r.unlocked() || r.hp() <= 0) continue; const frac = r.hp() / PLANTS[r.kind].hp[r.stage() - 1]; if (frac < worst) { @@ -634,12 +687,12 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { function hitBoss(b: BossInst, dmg: number, owner: number): void { if (boss() !== b) return; - b.hp.set(b.hp() - dmg); + b.hp -= dmg; sfx("hit"); primroseMend(owner); const p = roster[owner]; if (p) grantGlow(p, dmg); - if (b.hp() <= 0) advanceBoss(b, true); + if (b.hp <= 0) advanceBoss(b, true); } function advanceBoss(b: BossInst, broken: boolean): void { @@ -651,12 +704,13 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { toast(`THE CARD TIMES OUT: ${b.def.phases[idx].card}`); cardTimeouts.set(cardTimeouts() + 1); } - dropMotes(b.x(), b.y(), BOSS_PHASE_BOUNTY); + dropMotes(b.x, b.y, BOSS_PHASE_BOUNTY); + enemyShotCount = 0; enemyShots.set([]); // the break clears the sky sfx("bossbreak"); if (idx + 1 < b.def.phases.length) { b.phase.set(idx + 1); - b.hp.set(b.def.phases[idx + 1].hp); + b.hp = b.def.phases[idx + 1].hp; b.timeoutTicks = b.def.phases[idx + 1].timeout * TPS; b.spiral = 0; bossCard.set(b.def.phases[idx + 1].card); @@ -669,7 +723,6 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (b.mid) { midbossDone = true; if (broken) kills.set(kills() + 1); - unlock("primrose", "THE MOUNTAIN ANSWERS -- MOON PRIMROSE JOINS"); } else { bossDone = true; if (broken) kills.set(kills() + 1); @@ -746,7 +799,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { for (let i = 0; i < streams; i++) { add.push({ id: ++idSeq, kind: "orb", x: px() + (i - (streams - 1) / 2) * 10, y: py() - 10, - vx: 0, vy: -170, dmg: def.dmg[s], pierce: false, through: 0, homing: true, ret: false, hitCd: 0, owner, + vx: 0, vy: -170, dmg: def.dmg[s], pierce: false, through: 0, homing: true, + homeCd: 0, homeSpeed: 0, ret: false, hitCd: 0, owner, dead: false, }); } } else { @@ -760,7 +814,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { add.push({ id: ++idSeq, kind: "banana", x: px(), y: py() - 12, vx: 0, vy: -BANANA.throwVy[s], dmg: def.dmg[s], pierce: false, through: 0, - homing: false, ret: false, hitCd: 0, owner, + homing: false, homeCd: 0, homeSpeed: 0, ret: false, hitCd: 0, owner, dead: false, }); } if (paintOnlyShots && p.kind !== "primrose") { @@ -785,7 +839,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { add.push({ id: ++idSeq, kind: "orb", x: px(), y: py() - 8, vx: cosA(-32 + i * 7) * 120, vy: sinA(-32 + i * 7) * 120 - 60, - dmg: 24, pierce: true, through: 0, homing: true, ret: false, hitCd: 0, owner, + dmg: 24, pierce: true, through: 0, homing: true, + homeCd: 0, homeSpeed: 0, ret: false, hitCd: 0, owner, dead: false, }); } if (paintOnlyShots) { @@ -794,11 +849,13 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } else { playerShots.set([...playerShots(), ...add]); } - enemyShots.set(enemyShots().filter((sh) => { + const kept = enemyShots().filter((sh) => { const dx = sh.x - px(); const dy = sh.y - py(); return dx * dx + dy * dy > 70 * 70; - })); + }); + enemyShotCount = kept.length; + enemyShots.set(kept); } else { for (const r of roster) { if (!ready(r)) continue; @@ -844,7 +901,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { const p = active(); const def = PLANTS[p.kind]; const focusing = Boolean(held & BTN.SQUARE); - focus.set(focusing); + if (focus() !== focusing) focus.set(focusing); const speed = (def.speed / TPS) * (focusing ? FOCUS_RATE : 1); let dx = 0; let dy = 0; @@ -856,14 +913,18 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { dx *= 0.7071; dy *= 0.7071; } - lastDx.set(Math.sign(dx)); - if (dx !== 0) facing.set(dx < 0 ? -1 : 1); // mirror into the strafe, keep it after - px.set(Math.max(FIELD.x0 + PLAYER_INSET, Math.min(FIELD.x0 + FIELD.w - PLAYER_INSET, px() + dx * speed))); - py.set(Math.max(FIELD.y0 + PLAYER_INSET, Math.min(FIELD.y0 + FIELD.h - PLAYER_INSET, py() + dy * speed))); + const signedDx = Math.sign(dx); + if (lastDx() !== signedDx) lastDx.set(signedDx); + if (dx !== 0) { + const nextFacing = dx < 0 ? -1 : 1; + if (facing() !== nextFacing) facing.set(nextFacing); // mirror into the strafe, keep it after + px.set(Math.max(FX0 + PLAYER_INSET, Math.min(FX0 + FW - PLAYER_INSET, px() + dx * speed))); + } + if (dy !== 0) py.set(Math.max(FY0 + PLAYER_INSET, Math.min(FY0 + FH - PLAYER_INSET, py() + dy * speed))); if (wilting()) { wiltTicks--; - wiltSeconds.set(Math.max(0, Math.ceil(wiltTicks / TPS))); + if (wiltTicks % TPS === 0) wiltSeconds.set(Math.max(0, Math.ceil(wiltTicks / TPS))); if (wiltTicks <= 0) { outcome.set("eternal"); sfx("eternal"); @@ -875,18 +936,32 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (switchCd > 0) switchCd--; if (invulnTicks > 0) invulnTicks--; - invulnOn.set(invulnTicks > 0); - - for (const r of roster) { - if (r.spellCdTicks > 0) r.spellCdTicks--; - r.spellReady.set(1 - r.spellCdTicks / (PLANTS[r.kind].spell.cooldown * TPS)); + const nextInvuln = invulnTicks > 0; + if (invulnOn() !== nextInvuln) invulnOn.set(nextInvuln); + + for (let i = 0; i < roster.length; i++) { + const r = roster[i]; + if (r.spellCdTicks > 0) { + r.spellCdTicks--; + r.spellReady.set(1 - r.spellCdTicks / (PLANTS[r.kind].spell.cooldown * TPS)); + } } } + // The per-tick loops below run indexed, not for-of: QuickJS allocates an + // iterator result object per for-of step, which at swarm/danmaku counts + // is both interpreter time and the dominant GC feed. function tickFoes(): void { const fs = foes(); // escape removals swap in a fresh array; fs is the tick's snapshot - const hasUta = fs.some((f) => f.kind === "uta"); - for (const f of fs) { + let hasUta = false; + for (let i = 0; i < fs.length; i++) { + if (fs[i].kind === "uta") { + hasUta = true; + break; + } + } + for (let fi = 0; fi < fs.length; fi++) { + const f = fs[fi]; const def = FOES[f.kind]; const s = f.stage - 1; const slowed = tick < f.slowUntil; @@ -895,8 +970,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (f.kind === "usagi") { f.x += f.vx * spd; f.y += spd * 0.35; - if (f.x < FIELD.x0 + 14) f.vx = 1; - if (f.x > FIELD.x0 + FIELD.w - 14) f.vx = -1; + if (f.x < FX0 + 14) f.vx = 1; + if (f.x > FX0 + FW - 14) f.vx = -1; } else if (f.kind === "wisp" || f.kind === "uta") { if (f.y < f.hoverY) { f.y += spd; @@ -907,12 +982,12 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } else { f.y += spd * 1.4; // the song moves on } - if (f.x < FIELD.x0 + 14) f.vx = 1; - if (f.x > FIELD.x0 + FIELD.w - 14) f.vx = -1; + if (f.x < FX0 + 14) f.vx = 1; + if (f.x > FX0 + FW - 14) f.vx = -1; } else { f.y += spd; } - if (f.y > FIELD.y0 + FIELD.h + 18) { + if (f.y > FY0 + FH + 18) { foes.set(foes().filter((x) => x.id !== f.id)); // it drifts past the garden escaped.set(escaped() + 1); continue; @@ -967,7 +1042,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (skill === 0) { enemyFire(f.x, f.y + 8, v.vx, v.vy, "mochi", dmg); } else { - for (const a of [-4, 4]) { + for (let a = -4; a <= 4; a += 8) { enemyFire( f.x, f.y + 8, v.vx * cosA(a) - v.vy * sinA(a), @@ -1005,10 +1080,15 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (!b) return; const idx = b.phase(); // sway on the quantized sine - b.x.set(FIELD.x0 + FIELD.w / 2 + sinA(Math.floor((tick - b.born) / 24) % 64) * (FIELD.w * 0.26)); - if (b.y() < FIELD.y0 + 46) b.y.set(b.y() + 0.8); + const bx = FIELD.x0 + FIELD.w / 2 + sinA(Math.floor((tick - b.born) / 24) % 64) * (FIELD.w * 0.26); + b.x = bx; + let by = b.y; + if (by < FIELD.y0 + 46) { + by += 0.8; + b.y = by; + } b.timeoutTicks--; - bossCardSeconds.set(Math.max(0, Math.ceil(b.timeoutTicks / TPS))); + if (b.timeoutTicks % TPS === 0) bossCardSeconds.set(Math.max(0, Math.ceil(b.timeoutTicks / TPS))); if (b.timeoutTicks <= 0) { advanceBoss(b, false); return; @@ -1023,14 +1103,14 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { b.fireCd = Math.round(1.1 * TPS); for (let i = 0; i < 9; i++) { const a = A_DOWN + (i - 4) * 3; - enemyFire(b.x(), b.y() + 12, cosA(a) * speed, sinA(a) * speed, "amber", dmg); + enemyFire(bx, by + 12, cosA(a) * speed, sinA(a) * speed, "amber", dmg); } } if (b.fireCd2 <= 0) { b.fireCd2 = Math.round(2.6 * TPS); for (let i = 0; i < 12; i++) { const a = Math.round((i * 64) / 12) + ((tick >> 5) % 64); - enemyFire(b.x(), b.y(), cosA(a) * 46, sinA(a) * 46, "pink", dmg); + enemyFire(bx, by, cosA(a) * 46, sinA(a) * 46, "pink", dmg); } } return; @@ -1042,15 +1122,15 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { b.spiral += 3; for (let i = 0; i < 14; i++) { const a = Math.round((i * 64) / 14) + b.spiral; - enemyFire(b.x(), b.y(), cosA(a) * speed, sinA(a) * speed, "pink", dmg); + enemyFire(bx, by, cosA(a) * speed, sinA(a) * speed, "pink", dmg); } } if (b.fireCd2 <= 0) { b.fireCd2 = Math.round(1.7 * TPS); for (let i = -1; i <= 1; i++) { - const v = aimedAt(b.x(), b.y(), speed + 16); + const v = aimedAt(bx, by, speed + 16); enemyFire( - b.x(), b.y() + 10, + bx, by + 10, v.vx * cosA(i * 3) - v.vy * sinA(i * 3), v.vx * sinA(i * 3) + v.vy * cosA(i * 3), "cyan", dmg, @@ -1062,28 +1142,28 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (b.fireCd <= 0) { b.fireCd = 6; b.spiral += 5; - enemyFire(b.x(), b.y(), cosA(b.spiral) * speed, sinA(b.spiral) * speed, "pink", dmg); - enemyFire(b.x(), b.y(), cosA(b.spiral + 32) * speed, sinA(b.spiral + 32) * speed, "pink", dmg); + enemyFire(bx, by, cosA(b.spiral) * speed, sinA(b.spiral) * speed, "pink", dmg); + enemyFire(bx, by, cosA(b.spiral + 32) * speed, sinA(b.spiral + 32) * speed, "pink", dmg); } if (b.fireCd2 <= 0) { b.fireCd2 = Math.round(1.6 * TPS); - const v = aimedAt(b.x(), b.y(), speed + 30); - enemyFire(b.x() - 10, b.y() + 8, v.vx, v.vy, "mochi", dmg); - enemyFire(b.x() + 10, b.y() + 8, v.vx, v.vy, "mochi", dmg); + const v = aimedAt(bx, by, speed + 30); + enemyFire(bx - 10, by + 8, v.vx, v.vy, "mochi", dmg); + enemyFire(bx + 10, by + 8, v.vx, v.vy, "mochi", dmg); } } else { // THE ETERNAL NIGHT: twin counter-spirals + slow rings if (b.fireCd <= 0) { b.fireCd = 5; b.spiral += 3; - enemyFire(b.x(), b.y(), cosA(b.spiral) * 52, sinA(b.spiral) * 52, "pink", dmg); - enemyFire(b.x(), b.y(), cosA(-b.spiral) * 52, sinA(-b.spiral) * 52, "cyan", dmg); + enemyFire(bx, by, cosA(b.spiral) * 52, sinA(b.spiral) * 52, "pink", dmg); + enemyFire(bx, by, cosA(-b.spiral) * 52, sinA(-b.spiral) * 52, "cyan", dmg); } if (b.fireCd2 <= 0) { b.fireCd2 = Math.round(3.5 * TPS); for (let i = 0; i < 18; i++) { const a = Math.round((i * 64) / 18) + ((tick >> 5) % 64); - enemyFire(b.x(), b.y(), cosA(a) * 42, sinA(a) * 42, "amber", dmg); + enemyFire(bx, by, cosA(a) * 42, sinA(a) * 42, "amber", dmg); } } } @@ -1100,9 +1180,13 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { const pShots = playerShots(); const foeSnapshot = foes(); let targetBoss = boss(); + const targetBossX = targetBoss?.x ?? 0; + const targetBossY = targetBoss?.y ?? 0; + let targetBossRadius = targetBoss ? targetBoss.def.phases[targetBoss.phase()].size * 0.4 : 0; let removedP = false; let removedBanana = false; - for (const sh of pShots) { + for (let si = 0; si < pShots.length; si++) { + const sh = pShots[si]; if (sh.kind === "banana") { // out, turn, home, and into the hand if (!sh.ret) { @@ -1120,7 +1204,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (sh.hitCd > 0) sh.hitCd--; if (sh.hitCd <= 0) { let struck = false; - for (const f of foeSnapshot) { + for (let i = 0; i < foeSnapshot.length; i++) { + const f = foeSnapshot[i]; if (f.hp() <= 0) continue; const fdx = f.x - sh.x; const fdy = f.y - sh.y; @@ -1133,12 +1218,12 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (!struck) { const b = targetBoss; if (b) { - const br = b.def.phases[b.phase()].size * 0.4; - const bdx = b.x() - sh.x; - const bdy = b.y() - sh.y; - if (bdx * bdx + bdy * bdy <= br * br) { + const bdx = targetBossX - sh.x; + const bdy = targetBossY - sh.y; + if (bdx * bdx + bdy * bdy <= targetBossRadius * targetBossRadius) { hitBoss(b, sh.dmg, sh.owner); if (boss() !== b) targetBoss = null; + else targetBossRadius = b.def.phases[b.phase()].size * 0.4; struck = true; } } @@ -1157,12 +1242,15 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } continue; // a boomerang ignores the walls and the one-hit despawn } - if (sh.homing) { + if (sh.homing && (sh.homeCd ?? 0) > 0) { + sh.homeCd!--; + } else if (sh.homing) { // steer toward the nearest target (quantized lerp, then renormalize) let tx = 0; let ty = 0; let best = Infinity; - for (const f of foeSnapshot) { + for (let i = 0; i < foeSnapshot.length; i++) { + const f = foeSnapshot[i]; if (f.hp() <= 0) continue; const dx = f.x - sh.x; const dy = f.y - sh.y; @@ -1175,32 +1263,42 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } const b = targetBoss; if (b) { - const dx = b.x() - sh.x; - const dy = b.y() - sh.y; + const dx = targetBossX - sh.x; + const dy = targetBossY - sh.y; const d = dx * dx + dy * dy; if (d < best) { best = d; - tx = b.x(); - ty = b.y(); + tx = targetBossX; + ty = targetBossY; } } if (best < Infinity) { - const cur = Math.sqrt(sh.vx * sh.vx + sh.vy * sh.vy) || 1; + // homeSpeed is 0 until first use (shape-stable init), then frozen. + let cur = sh.homeSpeed!; + if (cur === 0) { + cur = Math.sqrt(sh.vx * sh.vx + sh.vy * sh.vy) || 1; + sh.homeSpeed = cur; + } const dx = tx - sh.x; const dy = ty - sh.y; const dl = Math.sqrt(dx * dx + dy * dy) || 1; - const nvx = sh.vx * 0.88 + (dx / dl) * cur * 0.12; - const nvy = sh.vy * 0.88 + (dy / dl) * cur * 0.12; + // Two original 12% steering samples collapsed into one 30 Hz + // sample: 1 - (1 - 0.12)^2 = 0.2256. + const nvx = sh.vx * 0.7744 + (dx / dl) * cur * 0.2256; + const nvy = sh.vy * 0.7744 + (dy / dl) * cur * 0.2256; const nl = Math.sqrt(nvx * nvx + nvy * nvy) || 1; sh.vx = (nvx / nl) * cur; sh.vy = (nvy / nl) * cur; } + sh.homeCd = 1; } - sh.x += sh.vx / TPS; - sh.y += sh.vy / TPS; + const sx = sh.x + sh.vx / TPS; + const sy = sh.y + sh.vy / TPS; + sh.x = sx; + sh.y = sy; if ( - sh.y < FIELD.y0 - 16 || sh.y > FIELD.y0 + FIELD.h + 16 || - sh.x < FIELD.x0 - 16 || sh.x > FIELD.x0 + FIELD.w + 16 + sy < FY0 - 16 || sy > FY0 + FH + 16 || + sx < FX0 - 16 || sx > FX0 + FW + 16 ) { sh.dead = true; removedP = true; @@ -1208,10 +1306,11 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } // hit foes let spent = false; - for (const f of foeSnapshot) { + for (let i = 0; i < foeSnapshot.length; i++) { + const f = foeSnapshot[i]; if (f.hp() <= 0) continue; - const dx = f.x - sh.x; - const dy = f.y - sh.y; + const dx = f.x - sx; + const dy = f.y - sy; if (dx * dx + dy * dy <= 13 * 13) { hitFoe(f, sh.dmg, sh.pierce, sh.owner); if (sh.through > 0) { @@ -1225,12 +1324,12 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (!spent) { const b = targetBoss; if (b) { - const br = b.def.phases[b.phase()].size * 0.4; - const dx = b.x() - sh.x; - const dy = b.y() - sh.y; - if (dx * dx + dy * dy <= br * br) { + const dx = targetBossX - sx; + const dy = targetBossY - sy; + if (dx * dx + dy * dy <= targetBossRadius * targetBossRadius) { hitBoss(b, sh.dmg, sh.owner); if (boss() !== b) targetBoss = null; + else targetBossRadius = b.def.phases[b.phase()].size * 0.4; spent = true; } } @@ -1243,7 +1342,10 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (removedP) { if (paintOnlyShots && !removedBanana) { let write = 0; - for (const shot of pShots) if (!shot.dead) pShots[write++] = shot; + for (let i = 0; i < pShots.length; i++) { + const shot = pShots[i]; + if (!shot.dead) pShots[write++] = shot; + } pShots.length = write; } else { playerShots.set(playerShots().filter((x) => !x.dead)); @@ -1254,29 +1356,36 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { // clears the sky, and this snapshot must see that) const eShots = enemyShots(); const act = active(); - const grazeR = act.kind === "catnip" ? CATNIP_GRAZE_R : GRAZE_R; + const grazeR2 = (act.kind === "catnip" ? CATNIP_GRAZE_R * CATNIP_GRAZE_R : GRAZE_R * GRAZE_R); + const hitR = HIT_R + 3; + const hitR2 = hitR * hitR; let removedE = false; - for (const sh of eShots) { - sh.x += sh.vx; - sh.y += sh.vy; + for (let si = 0; si < eShots.length; si++) { + const sh = eShots[si]; + // One read/write pair per axis; the bounds + range tests below reuse + // the locals (each avoided property slot is real interpreter time + // at 48 bullets x 60 ticks). + const sx = sh.x + sh.vx; + const sy = sh.y + sh.vy; + sh.x = sx; + sh.y = sy; if ( - sh.y > FIELD.y0 + FIELD.h + 12 || sh.y < FIELD.y0 - 12 || - sh.x < FIELD.x0 - 12 || sh.x > FIELD.x0 + FIELD.w + 12 + sy > FY0 + FH + 12 || sy < FY0 - 12 || + sx < FX0 - 12 || sx > FX0 + FW + 12 ) { sh.dead = true; removedE = true; continue; } - const dx = sh.x - pxv; - const dy = sh.y - pyv; + const dx = sx - pxv; + const dy = sy - pyv; const d2 = dx * dx + dy * dy; - const hitR = HIT_R + 3; - if (d2 <= hitR * hitR) { + if (d2 <= hitR2) { sh.dead = true; removedE = true; hurtPlayer(sh.dmg); } else { - if (!sh.grazed && d2 <= grazeR * grazeR && invulnTicks <= 0) { + if (!sh.grazed && d2 <= grazeR2 && invulnTicks <= 0) { sh.grazed = true; graze.set(graze() + 1); score.set(score() + 10); @@ -1288,15 +1397,22 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (removedE) { if (paintOnlyShots) { let write = 0; - for (const shot of eShots) if (!shot.dead) eShots[write++] = shot; + for (let i = 0; i < eShots.length; i++) { + const shot = eShots[i]; + if (!shot.dead) eShots[write++] = shot; + } eShots.length = write; + enemyShotCount = write; } else { - enemyShots.set(enemyShots().filter((x) => !x.dead)); + const kept = enemyShots().filter((x) => !x.dead); + enemyShotCount = kept.length; + enemyShots.set(kept); } } // body rams - for (const f of foeSnapshot) { + for (let i = 0; i < foeSnapshot.length; i++) { + const f = foeSnapshot[i]; if (f.hp() <= 0) continue; const dx = f.x - pxv; const dy = f.y - pyv; @@ -1306,7 +1422,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { // motes const ms = motes(); let removedM = false; - for (const m of ms) { + for (let mi = 0; mi < ms.length; mi++) { + const m = ms[mi]; if (pyv < POC_Y || Math.abs(m.x - pxv) + Math.abs(m.y - pyv) < 34) { // magnet: above the PoC line, or close by const dx = pxv - m.x; @@ -1317,7 +1434,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { } else { m.y += 44 / TPS; } - if (m.y > FIELD.y0 + FIELD.h + 10) { + if (m.y > FY0 + FH + 10) { m.dead = true; removedM = true; motesMissed.set(motesMissed() + 1); @@ -1328,6 +1445,11 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (dx * dx + dy * dy <= 12 * 12) { m.dead = true; removedM = true; + const collected = motesCollected() + 1; + motesCollected.set(collected); + if (collected === PRIMROSE_UNLOCK_MOTES) { + unlock("primrose", "28 MOON MOTES ANSWER -- MOON PRIMROSE JOINS"); + } const p = active(); const worth = p.kind === "primrose" ? MOTE_GLOW * 2 : MOTE_GLOW; grantGlow(p, worth); @@ -1345,7 +1467,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { if (pressed & BTN.TRIANGLE) castSpell(); } tick++; - second.set(Math.floor(tick / TPS)); + if (tick % TPS === 0) second.set(tick / TPS); fxTick.set(tick); if (fxs().length > 0) { const cutoff = tick - FX_LIFE; @@ -1362,8 +1484,12 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { // runs its FULL ticksPerFrame() batch or none of it — see the header. // The whole frame runs inside one Solid batch: render effects fire once, // with final values, after the last micro-tick — not per signal write. + // The batch body is a reused binding, not a fresh closure per frame. + let frameButtons = 0; + const runFrameBody = () => frameBody(frameButtons); function frame(buttons: number): void { - batch(() => frameBody(buttons)); + frameButtons = buttons; + batch(runFrameBody); } function frameBody(buttons: number): void { @@ -1426,6 +1552,8 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { rescues: () => rescues, escaped: () => escaped(), hitsTaken: () => hitsTaken(), + motesCollected: () => motesCollected(), + primroseUnlockedAt: () => roster.find((r) => r.kind === "primrose")?.unlockedAt() ?? -1, motesMissed: () => motesMissed(), cardTimeouts: () => cardTimeouts(), rosterGlow: () => roster.map((r) => ({ kind: r.kind, stage: r.stage(), hp: r.hp(), glow: Math.round(r.glow()) })), @@ -1433,7 +1561,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { bulletCount: () => enemyShots().length, bossInfo: () => { const b = boss(); - return b ? { name: b.def.name, phase: b.phase(), hp: b.hp() } : null; + return b ? { name: b.def.name, phase: b.phase(), hp: b.hp } : null; }, playerPos: () => ({ x: Math.round(px()), y: Math.round(py()) }), }; @@ -1452,6 +1580,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { bestStage, escaped, hitsTaken, + motesCollected, motesMissed, cardTimeouts, px, diff --git a/demos/nightbloom/shot-orb.png b/demos/nightbloom/shot-orb.png index be2e956ccb20f260ee6cad233f3767810d359ce3..22663f3987f4b55e72dd3902c505a50e0b33b3da 100644 GIT binary patch delta 292 zcmV+<0o(qK0=@!}BYyz}Nkl>Uh*;Qo01+&0JcrnN3=d#w zV__wDur!)53lqlg#_DfG*ntmWv+TS-yV(>)R#w(OYq*)!sGpdRR*SlNR-wkbWxo*U zn&zxtlYrT&zO?4&*EKBw_gV`8TCq89=Ij(kgLN6I3qX@`fq#IR0!(iEuyA=lwB~r$ zF;@T^Hvj}sg@0LM6w z5N-f=f9*x25Qv{=B?$m8pDqB-cL69O2Qij_zH^J;0$7Mxxpz;YBkb!p2)uLq{9@81 qy2`zCtJqIRZym&>=2=-;DbxUHRWNX8C`D@k0000gB1P$)5UU-MQ@WI zqu@6gPye6foBiKc)|0l50QvO2@6-SP{`~`Eki{_kDf}te@=z%++BgE_bC4x4%R%@{ z@oRVpfba>m-TxN|H~y!IBhVcHv-E)BCKOC}2Y^C=ZVouBw`&n4-su_wpcKASpbsU^ zksYx^W;N~8zzVT$3?IYrRn13WOQ(rf(JniJvSB}eIaUXN90$TQ%Z4xj@*T*RprA&^ sH1#<;07VBjOc%>X!6+C7qhQzp0CxB>U-Ihv-v9sr07*qoM6N<$f>zRRJOBUy diff --git a/native/build.rs b/native/build.rs index ae0e552..7a36467 100644 --- a/native/build.rs +++ b/native/build.rs @@ -57,6 +57,9 @@ fn main() { // skip raw frame dumps while still using the capture window to exit. let arena_bytes = env::var("POCKETJS_ARENA_BYTES").unwrap_or_default(); let bench_dump_frames = env::var("POCKETJS_BENCH_DUMP_FRAMES").unwrap_or_default(); + // "1" -> the bench summary line is followed by one JSONL line per window + // frame (js/tick/draw/render/work), for locating spike frames exactly. + let bench_trace = env::var("POCKETJS_BENCH_TRACE").unwrap_or_default(); println!("cargo:rustc-env=POCKETJS_APP={app}"); println!("cargo:rustc-env=POCKETJS_CAPTURE_INPUT={capture_input}"); @@ -65,6 +68,7 @@ fn main() { println!("cargo:rustc-env=POCKETJS_CAP_N={cap_n}"); println!("cargo:rustc-env=POCKETJS_ARENA_BYTES={arena_bytes}"); println!("cargo:rustc-env=POCKETJS_BENCH_DUMP_FRAMES={bench_dump_frames}"); + println!("cargo:rustc-env=POCKETJS_BENCH_TRACE={bench_trace}"); println!("cargo:rerun-if-env-changed=POCKETJS_APP"); println!("cargo:rerun-if-env-changed=POCKETJS_CAPTURE_INPUT"); println!("cargo:rerun-if-env-changed=POCKETJS_TRACE"); @@ -72,6 +76,7 @@ fn main() { println!("cargo:rerun-if-env-changed=POCKETJS_CAP_N"); println!("cargo:rerun-if-env-changed=POCKETJS_ARENA_BYTES"); println!("cargo:rerun-if-env-changed=POCKETJS_BENCH_DUMP_FRAMES"); + println!("cargo:rerun-if-env-changed=POCKETJS_BENCH_TRACE"); if let Ok(entries) = fs::read_dir(dist) { for e in entries.flatten() { println!("cargo:rerun-if-changed={}", e.path().display()); diff --git a/native/src/main.rs b/native/src/main.rs index c4b6e9a..b147c33 100644 --- a/native/src/main.rs +++ b/native/src/main.rs @@ -42,6 +42,11 @@ static APP_PAK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.pak")); #[cfg(feature = "bench")] static POCKETJS_APP_NAME: &str = env!("POCKETJS_APP"); static POCKETJS_TRACE: &str = env!("POCKETJS_TRACE"); +// "1" -> append one JSONL line per bench-window frame after the summary +// (spike-frame forensics; PPSSPP timing is deterministic, so one traced run +// names the exact frame and pipeline stage). +#[cfg(feature = "bench")] +static POCKETJS_BENCH_TRACE: &str = env!("POCKETJS_BENCH_TRACE"); // Build-time scripted input for deterministic PPSSPPHeadless captures // (test/e2e-ppsspp.ts). Baked by build.rs from the POCKETJS_CAPTURE_INPUT env; @@ -170,6 +175,10 @@ impl BenchState { #[cfg(feature = "bench")] static mut BENCH: BenchState = BenchState::new(); +/// Per-frame trace lines, accumulated in memory and flushed with the summary +/// (streaming writes per frame would drag file I/O into the loop timings). +#[cfg(feature = "bench")] +static mut BENCH_TRACE_LINES: Option = None; #[cfg(feature = "bench")] #[inline] @@ -266,6 +275,12 @@ unsafe fn bench_record_frame( let draw_us = after_draw.saturating_sub(after_tick); let render_us = after_render.saturating_sub(after_draw).saturating_sub(present_us); let work_us = after_render.saturating_sub(t0).saturating_sub(present_us); + if POCKETJS_BENCH_TRACE == "1" { + let lines = BENCH_TRACE_LINES.get_or_insert_with(alloc::string::String::new); + lines.push_str(&alloc::format!( + "{{\"f\":{frame_count},\"js\":{js_us},\"jobs\":{jobs_us},\"tick\":{tick_us},\"draw\":{draw_us},\"render\":{render_us},\"work\":{work_us}}}\n", + )); + } BENCH.frames = BENCH.frames.saturating_add(1); BENCH.js_sum_us = BENCH.js_sum_us.saturating_add(js_us); BENCH.jobs_sum_us = BENCH.jobs_sum_us.saturating_add(jobs_us); @@ -330,6 +345,9 @@ unsafe fn bench_maybe_flush(frame_count: u32) { arena_stats.configured_bytes, ); bench_write(line.as_bytes()); + if let Some(lines) = BENCH_TRACE_LINES.take() { + bench_write(lines.as_bytes()); + } } unsafe fn boot() { diff --git a/scripts/bench-ppsspp.ts b/scripts/bench-ppsspp.ts index 26617fb..039a12a 100644 --- a/scripts/bench-ppsspp.ts +++ b/scripts/bench-ppsspp.ts @@ -10,14 +10,65 @@ import { $ } from "bun"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { scriptToMasks, type ScriptEvent } from "../host-sim/sim.ts"; +import { BTN } from "../spec/spec.ts"; interface Spec { app: string; + /** App bundle to build (defaults to `app`) — lets several windows share + * one demo under distinct report labels. */ + entry?: string; inputScript: string; capStart: number; capN: number; } +/** Compress a sim script (virtual-seconds press/hold events) into the baked + * "frame:mask,..." capture format — the PSP replays the exact per-frame mask + * timeline the deterministic sim tests drive, so a bench window lands on the + * same battle the suite proves. */ +function scriptToCaptureInput(script: ScriptEvent[], frames: number): string { + const { masks } = scriptToMasks(script, 60, frames); + const parts: string[] = []; + let last = -1; + for (let f = 0; f < frames; f++) { + if (masks[f] !== last) { + parts.push(`${f}:0x${masks[f].toString(16)}`); + last = masks[f]; + } + } + return parts.join(","); +} + +// THE MARKSMAN — the winning tape from test/nightbloom.sim.test.ts, verbatim. +// It survives to the diva and breaks all three cards, so boss-phase bench +// windows measure real saturated combat (the finale pegs the 48-bullet cap). +const MARKSMAN: ScriptEvent[] = (() => { + const T: ScriptEvent[] = [{ at: 1.0, press: BTN.START }]; + let dir: number = BTN.LEFT; + for (let t = 1.5; t < 186; t += 1.5) { + T.push({ at: t, hold: BTN.CROSS | dir }); + dir = dir === BTN.LEFT ? BTN.RIGHT : BTN.LEFT; + } + T.push({ at: 21.0, press: BTN.TRIANGLE }); + T.push({ at: 40.0, press: BTN.TRIANGLE }); + for (let t = 44; t <= 170; t += 9) { + T.push({ at: t, press: BTN.RTRIGGER }); + T.push({ at: t + 1.5, press: BTN.TRIANGLE }); + T.push({ at: t + 2.5, press: BTN.LTRIGGER }); + T.push({ at: t + 3.5, press: BTN.TRIANGLE }); + T.push({ at: t + 6, press: BTN.RTRIGGER }); + } + for (const t of [139.0, 157.0]) { + T.push({ at: t, press: BTN.RTRIGGER }); + T.push({ at: t + 1.5, press: BTN.TRIANGLE }); + T.push({ at: t + 3.0, press: BTN.LTRIGGER }); + } + return T.sort((a, b) => a.at - b.at); +})(); + +const MARKSMAN_INPUT = scriptToCaptureInput(MARKSMAN, 190 * 60); + interface BenchLine { app: string; frames: number; @@ -101,6 +152,35 @@ const SPECS: Spec[] = [ capStart: 1440, capN: 180, }, + { + // Moon-primrose unlock window (61..64 s): the 28th collected mote flips + // a pre-mounted roster card. This catches regressions that reintroduce a + // structural reveal or collide the unlock with the midboss defeat frame. + app: "nightbloom-unlock", + entry: "nightbloom", + inputScript: MARKSMAN_INPUT, + capStart: 3660, + capN: 180, + }, + { + // MIDNIGHT swarm on the marksman tape (108..111 s): the densest + // non-boss stretch — foes + homing player shots + ~17 enemy bullets. + app: "nightbloom-mid", + entry: "nightbloom", + inputScript: MARKSMAN_INPUT, + capStart: 6480, + capN: 180, + }, + { + // THE ETERNAL NIGHT (the diva's third card, 154..157 s): twin + // counter-spirals + rings peg the 48-bullet cap — the worst frame the + // game produces, and the stretch players report as laggy. + app: "nightbloom-boss", + entry: "nightbloom", + inputScript: MARKSMAN_INPUT, + capStart: 9240, + capN: 180, + }, ]; const pspUiDir = new URL("..", import.meta.url).pathname; @@ -110,6 +190,7 @@ let apps = ["stats"]; let timeout = Number(process.env.BENCH_PPSSPP_TIMEOUT || 60); let outDir = `${pspUiDir}dist/bench`; let dumpFrames = false; +let traceFrames = false; let memoryScan = false; let memoryStepBytes = 256 * KiB; let memorySafetyBytes = 512 * KiB; @@ -133,6 +214,8 @@ for (let i = 0; i < argv.length; i++) { if (a === "--out-dir") i++; } else if (a === "--dump-frames") { dumpFrames = true; + } else if (a === "--trace-frames") { + traceFrames = true; } else if (a === "--memory-scan") { memoryScan = true; } else if (a.startsWith("--memory-step-kib=") || a === "--memory-step-kib") { @@ -256,7 +339,7 @@ if (memoryScanReport) { } async function buildBenchEboot(spec: Spec, arenaBytes: number | null): Promise { - await $`bun scripts/psp.ts ${spec.app} --bench` + await $`bun scripts/psp.ts ${spec.entry ?? spec.app} --bench` .cwd(pspUiDir) .env({ ...process.env, @@ -265,6 +348,7 @@ async function buildBenchEboot(spec: Spec, arenaBytes: number | null): Promise JSON.parse(line) as { + f: number; js: number; jobs: number; tick: number; draw: number; render: number; work: number; + }); + if (!traceFrames && frameLines.length !== 0) { + throw new Error(`${spec.app} sample ${sample}: unexpected ${frameLines.length} frame trace lines`); + } + if (traceFrames && frameLines.length !== spec.capN) { + throw new Error(`${spec.app} sample ${sample}: expected ${spec.capN} frame trace lines, got ${frameLines.length}`); + } if (parsed.frames !== spec.capN || parsed.window_n !== spec.capN) { throw new Error(`${spec.app} sample ${sample}: bench window mismatch (${parsed.frames}/${parsed.window_n}, expected ${spec.capN})`); } + // Windows sharing one entry bundle report the entry's name (baked + // POCKETJS_APP) — relabel with the spec's unique window label. + parsed.app = spec.app; + for (const frame of frameLines) writeRaw({ kind: "frame", app: spec.app, sample, ...frame }); return { ...parsed, sample, host_wall_ms, arena_limit_bytes: arenaBytes }; } diff --git a/src/hot.ts b/src/hot.ts index 2ed1926..7ac7ca7 100644 --- a/src/hot.ts +++ b/src/hot.ts @@ -109,21 +109,35 @@ export interface ParticleBatch { reset(): void; push(x: number, y: number, size: number, color: number): void; flush(node: NodeMirror | undefined): void; + /** Direct-write fast path for per-frame swarm loops: write particle k's + * x/y/size at floats[4k..4k+2] and its ABGR color at words[4k+3], then + * flushCount(node, n). Skips one closure call per particle — measurable + * when a QuickJS loop repaints dozens of particles every frame. Use + * either push()+flush() or direct writes+flushCount() within one frame, + * not both. */ + readonly floats: Float32Array; + readonly words: Uint32Array; + readonly capacity: number; + flushCount(node: NodeMirror | undefined, count: number): void; } /** Reusable packed particle buffer. Coordinates and size share storage with * ABGR color words; PSP copies the batch into retained core capacity once * per frame. */ export function createParticleBatch(capacity: number): ParticleBatch { - const words = new Uint32Array(Math.max(1, capacity) * 4); + const cap = Math.max(1, capacity); + const words = new Uint32Array(cap * 4); const floats = new Float32Array(words.buffer); let count = 0; return { + floats, + words, + capacity: cap, reset(): void { count = 0; }, push(x: number, y: number, size: number, color: number): void { - if (count * 4 >= words.length) return; + if (count >= cap) return; const at = count * 4; floats[at] = x; floats[at + 1] = y; @@ -135,5 +149,10 @@ export function createParticleBatch(capacity: number): ParticleBatch { if (!node) return; getOps().setParticles?.(node.id, words, count); }, + flushCount(node: NodeMirror | undefined, n: number): void { + if (!node) return; + count = n > cap ? cap : n > 0 ? n : 0; + getOps().setParticles?.(node.id, words, count); + }, }; } diff --git a/test/hot.test.ts b/test/hot.test.ts index 93eac9b..d1e1de8 100644 --- a/test/hot.test.ts +++ b/test/hot.test.ts @@ -130,6 +130,29 @@ test("particle batches pack f32 geometry and ABGR words into one host call", () expect(of("setParticles")).toEqual([["setParticles", layer.id, 1, 3.5, -2, 8, 0xff112233]]); }); +test("particle batch direct writes flush through flushCount, clamped to capacity", () => { + const host = mockHost(); + host.ops.setParticles = (id, words, count) => { + const floats = new Float32Array(words.buffer); + calls.push(["setParticles", id, count, floats[4], floats[5], floats[6], words[7]]); + }; + installHost(host); + const layer = createElement("view"); + const batch = hot.createParticleBatch(2); + expect(batch.capacity).toBe(2); + // particle 1 via the direct-write fast path (no push closure) + batch.floats[4] = 9.5; + batch.floats[5] = -1; + batch.floats[6] = 6; + batch.words[7] = 0xffaabbcc; + batch.flushCount(layer, 2); + expect(of("setParticles")).toEqual([["setParticles", layer.id, 2, 9.5, -1, 6, 0xffaabbcc]]); + batch.flushCount(layer, 99); // over-capacity count is clamped + expect(of("setParticles")[1][2]).toBe(2); + batch.flushCount(layer, -3); // negative clamps to zero + expect(of("setParticles")[2][2]).toBe(0); +}); + test("hot.text on a bare text node works without a wrapper", () => { const run = createElement("text"); run.text = ""; diff --git a/test/nightbloom.sim.test.ts b/test/nightbloom.sim.test.ts index 215da3a..d1590d0 100644 --- a/test/nightbloom.sim.test.ts +++ b/test/nightbloom.sim.test.ts @@ -8,7 +8,7 @@ // // Two tapes: // THE MARKSMAN — a full clear (~183 s): the catnip opens the night alone, -// the midboss's fall rouses the healing gorilla, and the +// twenty-eight gathered moon motes rouse the healing gorilla, // rotation walks both forms // through the pilot seat to break the diva's last card. // THE SLEEPER — nobody home (~40 s): the lone catnip dies with no form @@ -42,7 +42,7 @@ const MARKSMAN_SECONDS = 190; // dawn settles at ~184.1 s const SLEEPER_SECONDS = 60; // the lone catnip falls at ~40 s // THE MARKSMAN — sweep-dodge and rotate. The switch presses no-op while a -// form is still locked, then pick the gorilla up when the night wakes it; +// form is still locked, then pick the gorilla up after the mote gate wakes it; // over the night every form flies. const MARKSMAN = (() => { const T: { at: number; press?: number; hold?: number }[] = [{ at: 1.0, press: BTN.START }]; @@ -163,15 +163,15 @@ describe("nightbloom: the night actually happened", () => { expect(treeHasText(t.tree, "DAWN BREAKS")).toBe(true); expect(treeHasText(t.tree, "THE DIVA FALLS SILENT")).toBe(true); // Act one: the score takes the stage (settled by the final frame). - expect(treeHasText(t.tree, "6830")).toBe(true); - expect(treeHasText(t.tree, "GRAZE: 56")).toBe(true); + expect(treeHasText(t.tree, "6930")).toBe(true); + expect(treeHasText(t.tree, "GRAZE: 58")).toBe(true); expect(treeHasText(t.tree, "FOES FELLED: 20")).toBe(true); // The whole roster woke and every form survived to see the sun. expect(treeHasText(t.tree, "GREATEST BLOOM: STAGE 3")).toBe(true); expect(treeHasText(t.tree, "SURVIVING FORMS: 2 OF 2 AWAKENED")).toBe(true); // Act two: ONE medal, stamped on and congratulating the wrong thing. expect(treeHasText(t.tree, "PINCUSHION")).toBe(true); - expect(treeHasText(t.tree, "STRUCK 21 TIMES AND PROUD")).toBe(true); + expect(treeHasText(t.tree, "STRUCK 19 TIMES AND PROUD")).toBe(true); } }); From 5bfd249606f29f7d4a3f75f901d7ce6c552e343f Mon Sep 17 00:00:00 2001 From: lavs <79836722+SummonLav@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:16:04 +0800 Subject: [PATCH 5/5] feat(nightbloom): rename the black moon cat Rename the playable cat, remove botanical cues from its PixelLab prompts, and replace its projectile with a pearly white prismatic paw while preserving the shared particle batch. --- demos/nightbloom/README.md | 8 ++++---- demos/nightbloom/app.tsx | 4 ++-- demos/nightbloom/data.ts | 31 ++++++++++++++++++++----------- demos/nightbloom/engine.ts | 2 +- demos/nightbloom/shot-orb.png | Bin 318 -> 586 bytes test/nightbloom.sim.test.ts | 10 +++++----- 6 files changed, 32 insertions(+), 23 deletions(-) diff --git a/demos/nightbloom/README.md b/demos/nightbloom/README.md index 4e384e8..79813da 100644 --- a/demos/nightbloom/README.md +++ b/demos/nightbloom/README.md @@ -42,7 +42,7 @@ transformation portrait (stage dress, flared wings, a phoenix final form that grows 52 -> 58 -> 66 px), announced by a flash ring and the boss's own cry — the diva chirps, the umbrella clangs. -**The roster wakes as you play.** Only the CATNIP KIT answers at dusk — the +**The roster wakes as you play.** Only the BLACK MOON CAT answers at dusk — the gorilla card shows a live moon-mote counter until 28 collected enemy drops wake the mountain. Its reveal no longer shares the midboss-defeat frame. **And no pilot switches @@ -54,7 +54,7 @@ awake, and the night takes the run. | form | wakes | shot | the ability | | --- | --- | --- | --- | -| CATNIP KIT | at dusk | homing orbs | a black-and-gold moon cat, a white moon waxing on its brow; **dances with death** — wider graze ring, double graze glow | +| BLACK MOON CAT | at dusk | pearly prismatic homing paws | a black-and-gold moon cat, a white moon waxing on its brow; **dances with death** — wider graze ring, double graze glow | | MOON PRIMROSE | collect 28 enemy-drop moon motes | **banana boomerangs** | a hulking gorilla with carved abs and the sweetest little face; at most three aloft, each cuts through on the way out AND the way home, every damaging touch heals the most wounded form by 2, and only a caught banana can be thrown again (the HUD counts his hand); motes are worth double glow | Spell cards reinforce each role: NINE LIVES (homing burst and nearby clear), @@ -105,9 +105,9 @@ browser's autoplay policy. The headless sim and the PSP never install a sink, and the simulation is byte-identical either way. `test/nightbloom.sim.test.ts` drives two tapes through the headless sim -host: THE MARKSMAN, a full clear (~163 s — the catnip opens alone, the +host: THE MARKSMAN, a full clear (~163 s — the black moon cat opens alone, the moon-mote gate wakes the gorilla, and both forms see the dawn), -and THE SLEEPER, whose lone catnip falls at ~34 s with nobody awake to +and THE SLEEPER, whose lone black moon cat falls at ~34 s with nobody awake to switch to. It asserts repeat-identity, chaos immunity, strict 4 Hz / 2 Hz subsampling of both tapes, cross-rate byte-equal outcome screens, augury effect timing, and the exact score / graze / kill / bloom diff --git a/demos/nightbloom/app.tsx b/demos/nightbloom/app.tsx index a9c686b..6a2525d 100644 --- a/demos/nightbloom/app.tsx +++ b/demos/nightbloom/app.tsx @@ -771,7 +771,7 @@ function NativePlayerShotLayer(props: { game: Nightbloom }) { for (let i = 0; i < batch.capacity; i++) { const at = i * 4; // The Image node's PixelLab texture is shared by the whole particle - // batch. White keeps its authored mint/lime palette intact; there is + // batch. White keeps its authored pearl/prismatic palette intact; there is // still only one retained node and one packed host call per repaint. floats[at + 2] = 10; words[at + 3] = 0xffffffff; @@ -1795,7 +1795,7 @@ function CodexBestiary() { )} - ONLY THE CATNIP FLIES AT DUSK. + ONLY THE BLACK MOON CAT FLIES AT DUSK. ASCEND ONCE: THE SAPLING WAKES. FELL THE UMBRELLA FOR THE APE. diff --git a/demos/nightbloom/data.ts b/demos/nightbloom/data.ts index f1bd288..98a9fb2 100644 --- a/demos/nightbloom/data.ts +++ b/demos/nightbloom/data.ts @@ -79,6 +79,10 @@ const PLANT_STYLE = "adorable kawaii chibi plant creature for a pixel art garden defense game, huge sparkling eyes, " + "tiny blushing cheeks, soft rounded shapes, pastel colors with a gentle night glow, " + "clean thick outline, single centered character, full body"; +const BLACK_MOON_CAT_STYLE = + "adorable kawaii chibi celestial cat spirit for a pixel art night defense game, huge sparkling eyes, " + + "tiny blushing cheeks, sleek midnight-black white and gold palette, soft rounded shapes, gentle moon glow, " + + "no botanical elements, no plants, no leaves, no flowers, no green, clean thick outline, single centered character, full body"; const FOE_STYLE = "adorable kawaii chibi yokai spirit for a pixel art night defense game, plump rounded body, " + "big shiny puppy eyes, tiny stubby limbs, blushing cheeks, more cute than scary, " + @@ -131,8 +135,8 @@ export const PLANT_ORDER: PlantId[] = ["catnip", "primrose"]; export const PLANTS: Record = { catnip: { id: "catnip", - name: "CATNIP KIT", - stageNames: ["CATNIP KIT", "NEKOMATA", "NINELIVES"], + name: "BLACK MOON CAT", + stageNames: ["BLACK MOON CAT", "NEKOMATA", "NINELIVES"], hp: [110, 140, 170], armor: [0, 0, 0], speed: 120, @@ -169,7 +173,7 @@ export const MOTE_GLOW = 8; export const GRAZE_GLOW = 2; /** Graze radius around the hitbox, px. */ export const GRAZE_R = 11; -/** The catnip dances with death: wider graze ring, double its glow. */ +/** The black moon cat dances with death: wider graze ring, double its glow. */ export const CATNIP_GRAZE_R = 16; export const CATNIP_GRAZE_MULT = 2; /** The gorilla guards the garden: every damaging banana touch heals the @@ -428,10 +432,15 @@ export const MOTE_SPRITE = "mote.png"; // chains (stage II from I, III from II) require the base to exist first. // --------------------------------------------------------------------------- -function plantArt(def: PlantDef, stagePrompts: [string, string, string], baseSeed: number): ArtEntry[] { +function plantArt( + def: PlantDef, + stagePrompts: [string, string, string], + baseSeed: number, + style = PLANT_STYLE, +): ArtEntry[] { return stagePrompts.map((p, i) => ({ name: def.sprites[i], - prompt: `${p}, ${PLANT_STYLE}`, + prompt: `${p}, ${style}`, w: UNIT, h: UNIT, seed: baseSeed + i, @@ -487,10 +496,10 @@ export const ART: ArtEntry[] = [ initFrom: "p-primrose-2.png", initStrength: 320, }, ...plantArt(PLANTS.catnip, [ - "tiny baby kitten sprout, small and round, sleek black fur with golden paws chest and ear tips, a white crescent moon mark on its forehead, leaf ears, huge adorable eyes, curled leaf tail", - "the same black and gold cat grown into a young sleek two-tailed cat blossom, taller now, white crescent moon mark glowing on its forehead, golden bell collar, playful grin, two swishing leaf tails", - "the same black and gold cat fully grown, a large regal spirit cat, many glowing petal tails fanned wide, a bright white full moon mark shining on its forehead, tiny golden crown, sparkling whiskers", - ], 1030), + "tiny black moon kitten, small and round, sleek midnight-black fur with golden paws chest and ear tips, a white crescent moon mark on its forehead, natural cat ears, huge adorable eyes, curled black tail, no plants, no leaves, no green", + "the same black and gold moon cat grown into a young sleek two-tailed nekomata, taller now, white crescent moon mark glowing on its forehead, golden bell collar, playful grin, two swishing black tails, no plants, no leaves, no green", + "the same black and gold moon cat fully grown, a large regal spirit cat, nine dark celestial tails fanned wide, a bright white full moon mark shining on its forehead, tiny golden crown, sparkling whiskers, no plants, no leaves, no green", + ], 1030, BLACK_MOON_CAT_STYLE), // --- foes (32x32, transparent, walk west) -------------------------------- ...foeArt(FOES.wisp, [ "floating paper lantern ghost with a tiny warm flame heart, chubby cheeks and a happy grin, soft ragged paper skirt", @@ -515,10 +524,10 @@ export const ART: ArtEntry[] = [ // --- projectiles + mote (32x32, transparent, drawn at 16px) --------------- { name: "shot-orb.png", - prompt: `bright emerald cat-paw comet projectile, four tiny lime toe sparks around a mint crescent core, friendly player attack, unmistakable cat magic, no pink, no red, ${SHOT_STYLE}`, + prompt: `pearly white luminous cat-paw projectile, brilliant white paw-pad core, opalescent rainbow gradient only along the edges shifting through icy cyan violet and soft gold, prismatic shimmer, friendly celestial cat magic, unmistakable player attack, no green, not pink-dominant, ${SHOT_STYLE}`, w: SHOT, h: SHOT, - seed: 3012, + seed: 3021, transparent: true, }, { name: "shot-mochi.png", prompt: `small round white mochi rice cake, ${SHOT_STYLE}`, w: SHOT, h: SHOT, seed: 3003, transparent: true }, diff --git a/demos/nightbloom/engine.ts b/demos/nightbloom/engine.ts index 11a5f6f..b754d42 100644 --- a/demos/nightbloom/engine.ts +++ b/demos/nightbloom/engine.ts @@ -384,7 +384,7 @@ export function createNightbloom(options: NightbloomOptions = {}): Nightbloom { glow: cell(0), spellReady: cell(1), spellCdTicks: 0, - unlocked: cell(i === 0), // only the catnip answers at dusk + unlocked: cell(i === 0), // only the black moon cat answers at dusk unlockedAt: cell(-1), })); diff --git a/demos/nightbloom/shot-orb.png b/demos/nightbloom/shot-orb.png index 22663f3987f4b55e72dd3902c505a50e0b33b3da..5546a006b94c4efb94fdc02ab4f68c446b7656d1 100644 GIT binary patch delta 562 zcmV-20?qxt0?GuCBYy%BNklsr@r zT6N}Gd`y+3RL^XV6h$)hCe;yu=5JH#WUL(`>d)%)sc^+xfPdOuRDU_CH4@mlaUNry zILFRK^|;Lev=BHGvEe*B2LMFH6M&z}9_s`S5N;K~`p~O)QQ(|l+zi06!_vfyfarHQ zkyHYFKgKNq5D9!LTh2dobD~+x%I}Wk!x*w&1<)v_*}3VyL%B9~Ul7pRbwMoOXyy=G z!JObS9??M7bAJHb^GHs}I1Ug3V-4ZtUI@{rF_s}B*186p2=*+p?*M>lk1zwkZhb8s zk#giVloymUjm)+jo9#a`1z@Aln7@+cW(7c?cow^_Uu?)V_4jjq-V6Y3T3I1anXn~* zD(fN4Spk^l071w~U;tE(xg<1>p$af=>YNM<00E)Q{(s|~geb(dlGN(tgZypFIT?}* z^$+Jx01#?Y#J}sjD>`v*Y(}ok4FHnPmGgMuK-p^T0*v>jokO7EsE%SgLIT=8ggDz7 z)Rd>4T>w{ig4DpJUXD(}zmm}0UrLIn5w=C6v3xC+rEts|onzjUD;aEmG%x7XqXP@c zMWW-E&MSTZxvG86=sy9(-TSdyh+sT*-nCBJA7v>>Uh*;Qo01+&0JcrnN3=d#w zV__wDur!)53lqlg#_DfG*ntmWv+TS-yV(>)R#w(OYq*)!sGpdRR*SlNR-wkbWxo*U zn&zxtlYrT&zO?4&*EKBw_gV`8TCq89=Ij(kgLN6I3qX@`fq#IR0!(iEuyA=lwB~r$ zF;@T^Hvj}sg@0LM6w z5N-f=f9*x25Qv{=B?$m8pDqB-cL69O2Qij_zH^J;0$7Mxxpz;YBkb!p2)uLq{9@81 qy2`zCtJqIRZym&>=2=-;DbxUHRWNX8C`D@k0000 { T.push({ at: t, hold: BTN.CROSS | dir }); dir = dir === BTN.LEFT ? BTN.RIGHT : BTN.LEFT; } - // dusk: the catnip hunts alone + // dusk: the black moon cat hunts alone T.push({ at: 21.0, press: BTN.TRIANGLE }); // NINE LIVES T.push({ at: 40.0, press: BTN.TRIANGLE }); // NINE LIVES // from midnight: 9 s cycles — lantern seat, STONEHEART (heal + shield), @@ -175,7 +175,7 @@ describe("nightbloom: the night actually happened", () => { } }); - test("the sleeper's lone catnip falls with nobody to switch to", () => { + test("the sleeper's lone black moon cat falls with nobody to switch to", () => { for (const t of [s60, s2]) { expect(treeHasText(t.tree, "ETERNAL NIGHT")).toBe(true); expect(treeHasText(t.tree, "THE GARDEN FALLS DARK")).toBe(true);