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 0000000..b41778a Binary files /dev/null and b/assets/screenshots/nightbloom.png differ diff --git a/core/src/draw.rs b/core/src/draw.rs index b1c4558..60f2cdd 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,64 @@ 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 (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 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..8c5e8da 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,66 @@ 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 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 new file mode 100644 index 0000000..79813da --- /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 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 + +```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 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 +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 — two forms, two answers + +| form | wakes | shot | the ability | +| --- | --- | --- | --- | +| 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), +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 +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 black moon cat opens alone, the +moon-mote gate wakes the gorilla, and both forms see the dawn), +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 +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..6a2525d --- /dev/null +++ b/demos/nightbloom/app.tsx @@ -0,0 +1,1871 @@ +// 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, 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"; +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, + MAX_ENEMY_SHOTS, + MAX_MOTES, + PRIMROSE_UNLOCK_MOTES, + STAMP_AT, + STAMP_IMPACT, + type EnemyShot, + type FloatFx, + type FoeInst, + type Nightbloom, + type PlantState, + type PlayerShot, +} 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 + + + ); +} + +/** 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"} + + + {/* 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 + + + ); +} + +// --------------------------------------------------------------------------- +// 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 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, i) => ( + { + const index = i(); + nodes[index] = node; + syncStar(index, g.fxTick()); + }} + style={{ insetL: s.x, insetT: 0, translateY: s.y }} + /> + )} + + + ); +} + +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(); + // 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.flushCount(layer, STARS.length); + }; + onFrame(sync); + return ( + { + layer = node; + sync(); + }} + /> + ); +} + +function Starfield(props: { game: Nightbloom }) { + return hot.supportsParticles() ? : ; +} + +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]; + }; + /** 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; + 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 = 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 ( + { + playerNode = node; + syncAppearance(); + syncPosition(); + }} + style={{ + 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 }} + /> + + + + + ); +} + +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 ( + + + + + + + ); +} + +const FOE_POOL_SIZE = 6; +const FOE_POOL = Array.from({ length: FOE_POOL_SIZE }, (_, i) => i); + +interface FoeSlot { + root?: NodeMirror; + image?: NodeMirror; + hp?: NodeMirror; + foeId?: number; +} + +/** 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]; + 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])); + } + for (let i = nextVisible; i < visible; i++) { + slots[i].foeId = undefined; + 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 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(); + 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}> + + + + ); + }} + + ); +} + +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" ? ( + + ) : ( + + ); +} + +/** 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. 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(); + let n = shots.length; + if (n > batch.capacity) n = batch.capacity; + for (let i = 0; i < n; i++) { + const shot = shots[i]; + const at = i * 4; + floats[at] = shot.x - offX; + floats[at + 1] = shot.y - offY; + words[at + 3] = shot.color; + } + batch.flushCount(layer, n); + }; + onFrame(sync); + return ( + { + 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(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 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; + } + 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(); + let n = 0; + for (let i = 0; i < shots.length && n < batch.capacity; i++) { + const shot = shots[i]; + if (shot.kind === "banana") continue; + const at = n * 4; + floats[at] = shot.x - offX; + floats[at + 1] = shot.y - offY; + n++; + } + batch.flushCount(layer, n); + }; + onFrame(sync); + return ( + { + layer = node; + sync(); + }} + /> + ); +} + +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) => }; + } + return ( + <> + + + + ); +} + +function MoteNode(props: { mote: MovingEntity; movers: MoverRegistry }) { + return ( + + ); +} + +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(); + let n = motes.length; + if (n > batch.capacity) n = batch.capacity; + for (let i = 0; i < n; i++) { + const mote = motes[i]; + const at = i * 4; + floats[at] = mote.x - offX; + floats[at + 1] = mote.y - offY; + } + batch.flushCount(layer, n); + }; + 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 = () => { + 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} + + ); +} + +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 = []; + 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 ( + + + + + + + + + + + + > 3) & 1) === 0 ? 1 : 0.4 }} + > + LAST BREATH + {"SWITCH NOW O / L / R " + g.wiltSeconds() + "s"} + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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; + let track: NodeMirror | undefined; + const sync = () => { + if (textW() <= props.width) { + hot.position(track, 0, 0); + return; + } + const span = textW() + 24; + hot.position(track, -((props.game.fxTick() * 0.6) % span), 0); + }; + onFrame(sync); + return ( + + { track = node; sync(); }}> + {props.text} + props.width}> + {props.text} + + + + ); +} + +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 = () => { + 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} + + + + + AUGURY + + + + + + 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 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"; + 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; + }; + 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 ( + + + ? + + + ? ? ? + + + + } + > + { + root = node; + lastState = ""; + syncNativeRoster(); + }} + > + = 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"} /> + + + + {native ? ( + { + hpFill = node; + lastHp = -1; + syncNativeRoster(); + }} + /> + ) : ( + + )} + + + {native && p.kind === "primrose" && ( + { + lockedOverlay = node; + lockedProgress = node; + lastState = ""; + syncNativeRoster(); + }} + style={{ height: 28, paddingT: 7 }} + >MOTES 0/28 + )} + + + ); +} + +/** 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())} + + + 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) => } + + + + + + ); +} + +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 BananaBadge(props: { game: Nightbloom }) { + return hot.supportsParticles() + ? + : ; +} + +function DeclarativeToastStack(props: { game: Nightbloom }) { + return ( + + + {(t) => ( + + {t.text} + + )} + + + ); +} + +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) +// --------------------------------------------------------------------------- + +/** 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 BLACK MOON CAT 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({ paintOnlyShots: hot.supportsParticles() }); + 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 0000000..37e5584 Binary files /dev/null and b/demos/nightbloom/bg-dawn.png differ diff --git a/demos/nightbloom/bg-eternal.png b/demos/nightbloom/bg-eternal.png new file mode 100644 index 0000000..3248c9a Binary files /dev/null and b/demos/nightbloom/bg-eternal.png differ diff --git a/demos/nightbloom/bg-title.png b/demos/nightbloom/bg-title.png new file mode 100644 index 0000000..8c36416 Binary files /dev/null and b/demos/nightbloom/bg-title.png differ diff --git a/demos/nightbloom/boss-kasa.png b/demos/nightbloom/boss-kasa.png new file mode 100644 index 0000000..cd9c6e1 Binary files /dev/null and b/demos/nightbloom/boss-kasa.png differ diff --git a/demos/nightbloom/boss-uta-1.png b/demos/nightbloom/boss-uta-1.png new file mode 100644 index 0000000..c763f9d Binary files /dev/null and b/demos/nightbloom/boss-uta-1.png differ diff --git a/demos/nightbloom/boss-uta-2.png b/demos/nightbloom/boss-uta-2.png new file mode 100644 index 0000000..28320d8 Binary files /dev/null and b/demos/nightbloom/boss-uta-2.png differ diff --git a/demos/nightbloom/boss-uta-3.png b/demos/nightbloom/boss-uta-3.png new file mode 100644 index 0000000..8f72744 Binary files /dev/null and b/demos/nightbloom/boss-uta-3.png differ diff --git a/demos/nightbloom/data.ts b/demos/nightbloom/data.ts new file mode 100644 index 0000000..98a9fb2 --- /dev/null +++ b/demos/nightbloom/data.ts @@ -0,0 +1,671 @@ +// demos/nightbloom/data.ts — NIGHTBLOOM's content tables: every plant form, +// foe, wave, spell card and sprite prompt in one pure-data module. engine.ts +// folds these tables over the virtual clock; gen-assets.ts reads the same +// tables to drive the PixelLab pipeline, so a creature's stats, its art +// prompt and its committed sprite can never drift apart. +// +// The game is a vertical danmaku shooter in the Imperishable Night grammar: +// the player PILOTS one plant at the bottom of a portrait playfield, the +// horde descends from the treeline above, and CIRCLE / L / R switch the +// piloted form mid-fight. On the 480x272 landscape screen the playfield is +// the classic arcade adaptation: a portrait column in the center, HUD +// panels on both sides. +// +// No imports from the framework or solid here: this module is shared by the +// game bundle AND the bun-side asset pipeline, so it stays platform-pure. +// +// All player-facing copy is ASCII (the Inter atlas has no CJK). GLYPHS below +// pins every codepoint dynamic text can produce (numbers, marks) so the font +// baker always slots them. + +export const GLYPHS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 .,:;!?+-x%/<>()[]'\"*="; + +// --------------------------------------------------------------------------- +// 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 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, " + + "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 — two pilotable plant forms +// --------------------------------------------------------------------------- + +export type PlantId = "primrose" | "catnip"; + +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. */ + 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", "primrose"]; + +export const PLANTS: Record = { + catnip: { + id: "catnip", + name: "BLACK MOON CAT", + stageNames: ["BLACK MOON CAT", "NEKOMATA", "NINELIVES"], + hp: [110, 140, 170], + armor: [0, 0, 0], + speed: 120, + artFacing: 1, + dmg: [7, 16, 20], + period: [0.34, 0.3, 0.26], + 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", + spell: { name: "NINE LIVES", hint: "9 HOMING ORBS + CLEAR NEAR", cooldown: 18 }, + }, + primrose: { + id: "primrose", + name: "MOON PRIMROSE", + stageNames: ["SPROUT", "FULL BLOOM", "MOONRISEN"], + hp: [100, 125, 150], + armor: [0, 0, 0], + speed: 110, + artFacing: -1, + 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. EVERY HIT MENDS THE MOST WOUNDED. MOTES x2", + spell: { name: "MOONRISE", hint: "HEAL 24 + 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 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 + * 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 + * 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. SPELL ORBS 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", "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). */ +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" + | "stamp" + | "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, + style = PLANT_STYLE, +): ArtEntry[] { + return stagePrompts.map((p, i) => ({ + name: def.sprites[i], + prompt: `${p}, ${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 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", + "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: `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: 3021, + 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 }, + { + 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", + 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..b754d42 --- /dev/null +++ b/demos/nightbloom/engine.ts @@ -0,0 +1,1613 @@ +// 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 { 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"; +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, + PRIMROSE_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); +// 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() +// --------------------------------------------------------------------------- + +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; + /** 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; +} + +export interface BossInst { + def: BossDef; + /** True for the midboss (waves resume after it breaks). */ + mid: boolean; + phase: Cell; + /** Plain hot fields; renderers redraw them from the shared frame tick. */ + hp: number; + x: number; + y: number; + 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; + /** 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. */ + vx: number; + vy: number; + dmg: number; + grazed: boolean; + /** Tick-local despawn marker; avoids Set allocation/hash work in QuickJS. */ + dead?: boolean; +} + +const ENEMY_SHOT_COLOR: Record = { + pink: 0xffd4a8f9, + cyan: 0xfff9e867, + amber: 0xff4dd3fc, + mochi: 0xffffffff, +}; + +export type PlayerShotKind = "orb" | "banana"; + +export interface PlayerShot { + id: number; + kind: PlayerShotKind; + x: number; + y: number; + vx: number; + vy: number; + dmg: number; + /** True damage ignores armor (spell orbs). */ + pierce: boolean; + /** 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 + * on a hit — it cuts through). */ + 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: number; + y: number; + /** Tick-local despawn marker; removed in one compacting filter pass. */ + dead?: boolean; +} + +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; + /** 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; + 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; + /** Ticks since the outcome settled — the end screens' own clock. */ + endTick: 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. */ +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 = 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); +/** 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 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); + 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 endTick = cell(0); + + 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 black moon cat 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 wallTicks = 0; + let wiltTicks = 0; + 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); + 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; + pendingEnemyFire = []; + enemyShotCount = 0; + nextWave = 0; + nextPhase = 0; + fireCd = 0; + switchCd = 0; + invulnTicks = 0; + midbossDone = false; + bossDone = false; + wallTicks = 0; + 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); + endTick.set(0); + wiltTicks = 0; + rescues = 0; + escaped.set(0); + hitsTaken.set(0); + motesCollected.set(0); + motesMissed.set(0); + cardTimeouts.set(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"); + } + } + + // -- 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: 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, + }, + ]); + } + + function spawnBoss(def: BossDef, mid: boolean): void { + boss.set({ + def, + mid, + phase: cell(0), + 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, + 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`); + } + + // 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[] = []; + let enemyShotCount = 0; + + function enemyFire(x: number, y: number, vx: number, vy: number, kind: EnemyShotKind, dmg: number): void { + 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. + // `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 (let i = 0; i < pendingEnemyFire.length; i++) shots.push(pendingEnemyFire[i]); + enemyShotCount = shots.length; + pendingEnemyFire.length = 0; + } else { + 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; + 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, dead: false }); + } + if (add.length > 0) motes.set([...motes(), ...add]); + } + + // -- damage ------------------------------------------------------------------ + + /** 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 (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) { + worst = frac; + target = r; + } + } + if (!target) return; + 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 { + // 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"); + 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]); + 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 -= dmg; + sfx("hit"); + primroseMend(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}`); + cardTimeouts.set(cardTimeouts() + 1); + } + 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 = 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); + } 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); + hitsTaken.set(hitsTaken() + 1); + 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 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: px() + (i - (streams - 1) / 2) * 10, y: py() - 10, + 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 { + // 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: px(), y: py() - 12, + vx: 0, vy: -BANANA.throwVy[s], dmg: def.dmg[s], pierce: false, through: 0, + homing: false, homeCd: 0, homeSpeed: 0, ret: false, hitCd: 0, owner, dead: false, + }); + } + 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"); + } + + 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: 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, + homeCd: 0, homeSpeed: 0, ret: false, hitCd: 0, owner, dead: false, + }); + } + if (paintOnlyShots) { + const shots = playerShots(); + for (const shot of add) shots.push(shot); + } else { + playerShots.set([...playerShots(), ...add]); + } + 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; + 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"); + 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); + if (focus() !== focusing) 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; + } + 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--; + if (wiltTicks % TPS === 0) 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--; + 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 + 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; + const rate = slowed ? 0.6 : 1; + const spd = (def.speed[s] / TPS) * rate; + if (f.kind === "usagi") { + f.x += f.vx * spd; + f.y += spd * 0.35; + 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; + } else if (f.station > 0) { + f.station--; + f.x += f.vx * spd * 0.5; + f.y += WORLD_DRIFT; // the view slides forward regardless + } else { + f.y += spd * 1.4; // the song moves on + } + if (f.x < FX0 + 14) f.vx = 1; + if (f.x > FX0 + FW - 14) f.vx = -1; + } else { + f.y += spd; + } + 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; + } + // 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]; + const skill = f.skill++ & 1; + if (f.kind === "wisp") { + 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") { + 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); + if (skill === 0) { + enemyFire(f.x, f.y + 8, v.vx, v.vy, "mochi", dmg); + } else { + for (let a = -4; a <= 4; a += 8) { + 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 (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, + ); + } + } + } + } + } + } + + function tickBoss(): void { + const b = boss(); + if (!b) return; + const idx = b.phase(); + // sway on the quantized sine + 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--; + if (b.timeoutTicks % TPS === 0) 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(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(bx, by, 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(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(bx, by, speed + 16); + enemyFire( + 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, + ); + } + } + } else if (idx === 1) { + // MOONFALL CANTATA: a spiral stream + aimed mochi pairs + if (b.fireCd <= 0) { + b.fireCd = 6; + b.spiral += 5; + 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(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(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(bx, by, cosA(a) * 42, sinA(a) * 42, "amber", dmg); + } + } + } + } + + 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 + 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 (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) { + sh.vy += BANANA.decel / TPS; + if (sh.vy >= 0) sh.ret = true; + } else { + 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 += sh.vx / TPS; + sh.y += sh.vy / TPS; + if (sh.hitCd > 0) sh.hitCd--; + if (sh.hitCd <= 0) { + let struck = false; + 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; + 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 = targetBoss; + if (b) { + 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; + } + } + } + if (struck) sh.hitCd = BANANA.hitCd; + } + if (sh.ret) { + const cdx = pxv - sh.x; + const cdy = pyv - sh.y; + if (cdx * cdx + cdy * cdy <= BANANA.catchR * BANANA.catchR) { + sh.dead = true; + removedP = true; + removedBanana = true; + sfx("mote"); // back in the hand + } + } + continue; // a boomerang ignores the walls and the one-hit despawn + } + 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 (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 d = dx * dx + dy * dy; + if (d < best) { + best = d; + tx = f.x; + ty = f.y; + } + } + const b = targetBoss; + if (b) { + const dx = targetBossX - sh.x; + const dy = targetBossY - sh.y; + const d = dx * dx + dy * dy; + if (d < best) { + best = d; + tx = targetBossX; + ty = targetBossY; + } + } + if (best < Infinity) { + // 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; + // 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; + } + const sx = sh.x + sh.vx / TPS; + const sy = sh.y + sh.vy / TPS; + sh.x = sx; + sh.y = sy; + if ( + sy < FY0 - 16 || sy > FY0 + FH + 16 || + sx < FX0 - 16 || sx > FX0 + FW + 16 + ) { + sh.dead = true; + removedP = true; + continue; + } + // hit foes + let spent = false; + for (let i = 0; i < foeSnapshot.length; i++) { + const f = foeSnapshot[i]; + if (f.hp() <= 0) continue; + 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) { + sh.through--; + } else { + spent = true; + break; + } + } + } + if (!spent) { + const b = targetBoss; + if (b) { + 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; + } + } + } + if (spent) { + sh.dead = true; + removedP = true; + } + } + if (removedP) { + if (paintOnlyShots && !removedBanana) { + let write = 0; + 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)); + } + } + + // 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 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 (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 ( + sy > FY0 + FH + 12 || sy < FY0 - 12 || + sx < FX0 - 12 || sx > FX0 + FW + 12 + ) { + sh.dead = true; + removedE = true; + continue; + } + const dx = sx - pxv; + const dy = sy - pyv; + const d2 = dx * dx + dy * dy; + if (d2 <= hitR2) { + sh.dead = true; + removedE = true; + hurtPlayer(sh.dmg); + } else { + if (!sh.grazed && d2 <= grazeR2 && invulnTicks <= 0) { + sh.grazed = true; + graze.set(graze() + 1); + score.set(score() + 10); + grantGlow(act, GRAZE_GLOW * (act.kind === "catnip" ? CATNIP_GRAZE_MULT : 1)); + sfx("graze"); + } + } + } + if (removedE) { + if (paintOnlyShots) { + let write = 0; + for (let i = 0; i < eShots.length; i++) { + const shot = eShots[i]; + if (!shot.dead) eShots[write++] = shot; + } + eShots.length = write; + enemyShotCount = write; + } else { + const kept = enemyShots().filter((x) => !x.dead); + enemyShotCount = kept.length; + enemyShots.set(kept); + } + } + + // body rams + 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; + if (dx * dx + dy * dy <= 14 * 14) hurtPlayer(RAM_DMG); + } + + // motes + const ms = motes(); + let removedM = false; + 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; + const dy = pyv - m.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + m.x += (dx / len) * (220 / TPS); + m.y += (dy / len) * (220 / TPS); + } else { + m.y += 44 / TPS; + } + if (m.y > FY0 + FH + 10) { + m.dead = true; + removedM = true; + motesMissed.set(motesMissed() + 1); + continue; + } + const dx = m.x - pxv; + const dy = m.y - pyv; + 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); + score.set(score() + 5); + sfx("mote"); + } + } + if (removedM) motes.set(motes().filter((x) => !x.dead)); + } + + 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++; + if (tick % TPS === 0) second.set(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. + // 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 { + frameButtons = buttons; + batch(runFrameBody); + } + + function frameBody(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") { + // 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; + } + 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(); + wallTicks += k; + for (let i = 0; i < k; i++) { + 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 + // 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, + 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()) })), + 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, + escaped, + hitsTaken, + motesCollected, + motesMissed, + cardTimeouts, + px, + py, + focus, + invuln: invulnOn, + activeIdx, + roster, + active, + lastDx, + facing, + wilting, + wiltSeconds, + foes, + boss, + bossCard, + bossCardSeconds, + bossFlash, + endTick, + 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 0000000..ad49691 Binary files /dev/null and b/demos/nightbloom/f-kasa-1.png differ diff --git a/demos/nightbloom/f-kasa-2.png b/demos/nightbloom/f-kasa-2.png new file mode 100644 index 0000000..d428002 Binary files /dev/null and b/demos/nightbloom/f-kasa-2.png differ diff --git a/demos/nightbloom/f-kasa-3.png b/demos/nightbloom/f-kasa-3.png new file mode 100644 index 0000000..abfa192 Binary files /dev/null and b/demos/nightbloom/f-kasa-3.png differ diff --git a/demos/nightbloom/f-usagi-1.png b/demos/nightbloom/f-usagi-1.png new file mode 100644 index 0000000..8b6ab8c Binary files /dev/null and b/demos/nightbloom/f-usagi-1.png differ diff --git a/demos/nightbloom/f-usagi-2.png b/demos/nightbloom/f-usagi-2.png new file mode 100644 index 0000000..9ebd8f6 Binary files /dev/null and b/demos/nightbloom/f-usagi-2.png differ diff --git a/demos/nightbloom/f-usagi-3.png b/demos/nightbloom/f-usagi-3.png new file mode 100644 index 0000000..2755efa Binary files /dev/null and b/demos/nightbloom/f-usagi-3.png differ diff --git a/demos/nightbloom/f-uta-1.png b/demos/nightbloom/f-uta-1.png new file mode 100644 index 0000000..9f0eb24 Binary files /dev/null and b/demos/nightbloom/f-uta-1.png differ diff --git a/demos/nightbloom/f-uta-2.png b/demos/nightbloom/f-uta-2.png new file mode 100644 index 0000000..9e5303a Binary files /dev/null and b/demos/nightbloom/f-uta-2.png differ diff --git a/demos/nightbloom/f-uta-3.png b/demos/nightbloom/f-uta-3.png new file mode 100644 index 0000000..a439d88 Binary files /dev/null and b/demos/nightbloom/f-uta-3.png differ diff --git a/demos/nightbloom/f-wisp-1.png b/demos/nightbloom/f-wisp-1.png new file mode 100644 index 0000000..1538c6f Binary files /dev/null and b/demos/nightbloom/f-wisp-1.png differ diff --git a/demos/nightbloom/f-wisp-2.png b/demos/nightbloom/f-wisp-2.png new file mode 100644 index 0000000..4092f8b Binary files /dev/null and b/demos/nightbloom/f-wisp-2.png differ diff --git a/demos/nightbloom/f-wisp-3.png b/demos/nightbloom/f-wisp-3.png new file mode 100644 index 0000000..30e218d Binary files /dev/null and b/demos/nightbloom/f-wisp-3.png differ 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/medal.png b/demos/nightbloom/medal.png new file mode 100644 index 0000000..b772c30 Binary files /dev/null and b/demos/nightbloom/medal.png differ diff --git a/demos/nightbloom/mote.png b/demos/nightbloom/mote.png new file mode 100644 index 0000000..a4209e5 Binary files /dev/null and b/demos/nightbloom/mote.png differ diff --git a/demos/nightbloom/p-catnip-1.png b/demos/nightbloom/p-catnip-1.png new file mode 100644 index 0000000..38a6466 Binary files /dev/null and b/demos/nightbloom/p-catnip-1.png differ diff --git a/demos/nightbloom/p-catnip-2.png b/demos/nightbloom/p-catnip-2.png new file mode 100644 index 0000000..e7377a8 Binary files /dev/null and b/demos/nightbloom/p-catnip-2.png differ diff --git a/demos/nightbloom/p-catnip-3.png b/demos/nightbloom/p-catnip-3.png new file mode 100644 index 0000000..16d96f6 Binary files /dev/null and b/demos/nightbloom/p-catnip-3.png differ diff --git a/demos/nightbloom/p-primrose-1.png b/demos/nightbloom/p-primrose-1.png new file mode 100644 index 0000000..548c66f Binary files /dev/null and b/demos/nightbloom/p-primrose-1.png differ diff --git a/demos/nightbloom/p-primrose-2.png b/demos/nightbloom/p-primrose-2.png new file mode 100644 index 0000000..7445b0e Binary files /dev/null and b/demos/nightbloom/p-primrose-2.png differ diff --git a/demos/nightbloom/p-primrose-3.png b/demos/nightbloom/p-primrose-3.png new file mode 100644 index 0000000..57636c9 Binary files /dev/null and b/demos/nightbloom/p-primrose-3.png differ diff --git a/demos/nightbloom/p-sakura-1.png b/demos/nightbloom/p-sakura-1.png new file mode 100644 index 0000000..2f55551 Binary files /dev/null and b/demos/nightbloom/p-sakura-1.png differ diff --git a/demos/nightbloom/p-sakura-2.png b/demos/nightbloom/p-sakura-2.png new file mode 100644 index 0000000..701ac06 Binary files /dev/null and b/demos/nightbloom/p-sakura-2.png differ diff --git a/demos/nightbloom/p-sakura-3.png b/demos/nightbloom/p-sakura-3.png new file mode 100644 index 0000000..29e1374 Binary files /dev/null and b/demos/nightbloom/p-sakura-3.png differ diff --git a/demos/nightbloom/sfx.ts b/demos/nightbloom/sfx.ts new file mode 100644 index 0000000..f90fb1b --- /dev/null +++ b/demos/nightbloom/sfx.ts @@ -0,0 +1,213 @@ +// demos/nightbloom/sfx.ts — the sound sink: NIGHTBLOOM's hit/impact audio, +// synthesized from oscillators (no assets, no pipeline) and installed as +// globalThis.__nightbloomSfx for the engine to poke. +// +// Sound is an OUTPUT, never an input: the engine emits SfxKind events from +// deterministic tick state and reads nothing back, so the simulation is +// byte-identical with or without a sink. Hosts without WebAudio (the +// headless sim, QuickJS on a PSP) simply never install one. +// +// Everything is feature-detected off globalThis — this module must load +// cleanly on every host, so it never names a DOM type. +// +// Browser autoplay policy: the AudioContext starts suspended until a user +// gesture; the first keydown / pointer press resumes it, which is also the +// gesture that presses START. + +import type { SfxKind } from "./data.ts"; + +interface Voice { + /** Oscillator frequency ramp, seconds, wave, peak gain, start delay. */ + f0: number; + f1: number; + dur: number; + type: "sine" | "square" | "sawtooth" | "triangle"; + gain: number; + delay?: number; +} + +interface NoiseBurst { + dur: number; + gain: number; + cutoff: number; + delay?: number; +} + +interface SfxDef { + voices: Voice[]; + noise?: NoiseBurst[]; + /** Minimum wall-clock ms between plays of this kind (spam guard). */ + throttle?: number; +} + +const ARP = (notes: number[], step: number, dur: number, gain: number, type: Voice["type"]): Voice[] => + 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 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: [ + { 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 0000000..9b3e41e Binary files /dev/null and b/demos/nightbloom/shot-banana.png differ diff --git a/demos/nightbloom/shot-mochi.png b/demos/nightbloom/shot-mochi.png new file mode 100644 index 0000000..1e5decb Binary files /dev/null and b/demos/nightbloom/shot-mochi.png differ diff --git a/demos/nightbloom/shot-orb.png b/demos/nightbloom/shot-orb.png new file mode 100644 index 0000000..5546a00 Binary files /dev/null and b/demos/nightbloom/shot-orb.png differ 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/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/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/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/scripts/bench-ppsspp.ts b/scripts/bench-ppsspp.ts index 57b3830..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; @@ -33,6 +84,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 +142,45 @@ 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, + }, + { + // 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; @@ -98,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; @@ -121,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") { @@ -181,6 +276,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", @@ -242,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, @@ -251,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/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..7ac7ca7 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,87 @@ 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; + /** 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 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 >= cap) 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); + }, + 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 1029614..d1e1de8 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,78 @@ 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("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 new file mode 100644 index 0000000..2ade988 --- /dev/null +++ b/test/nightbloom.sim.test.ts @@ -0,0 +1,187 @@ +// 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 black moon cat opens alone, +// 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 black moon cat 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 black moon cat 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 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 }]; + // 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 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), + // 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 }); + } + // 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 }); // MOONRISE + 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); + // Act one: the score takes the stage (settled by the final frame). + 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 19 TIMES AND PROUD")).toBe(true); + } + }); + + 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); + 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); + } + }); +});