diff --git a/CLAUDE.md b/CLAUDE.md index c3d92adf..15c0e780 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2303,6 +2303,35 @@ loadable play content. Everything a user does must be visible to connected peers (`scripts/level-templates.cjs` via author-templates' `kit` type, `seed: false`); a scene load now ends a running simulation first. Suites pack-refs, pack-undo, level-templates (`LIVE=1` runs it against a build's own feed + pack CDN). +- **ROADMAP 31 — TOWERS BECOMES A GAME** (31-towers; T1/T2 of the Quest feedback, "after the basic + level nothing happens, just like hooray"). Two core rules any game can use, and a core MODULE. + · `playReach.js` (pure leaf, vitest): `play.reach` (metres, OMITTED when absent) limits Interact / + Play grabs to that distance from the player's BODY — the segment feet..eye, never the eye (a crate + at your feet is 1.7 m from the eye). Desktop Play: `playInteractState.mode = 'toofar'` (the + crosshair says "Too far — get closer"), the press carries nothing, a carry is held inside the + reach (`carryLimit`); the editor's Interact cursor carry has no body and is unaffected. VR: + `gripTargetOf` refuses an out-of-reach body (`lastGripRefusalDebug`) and the grip buzzes `fail`. + Configure Scene ▸ Physics ▸ Play mode ▸ Limit grab reach. + · JUMP IN VR: the Character Controller node's `jumpHeight` jumps on right A in Interact walking + (`vrJumpHeight()`; A stays push-to-talk otherwise, `pttByA` so a switch mid-hold cannot strand the + mic). Desktop walk mode always jumped on Space. The capsule stands on dynamic pieces (a query + shape: it never shoves them). + · `src/modules/towers/` — a CORE module (the pong precedent: it may reach core internals through + PRIMED DYNAMIC imports; `api.spawn` still does not exist), dormant unless the scene holds the + `Towers game` marker. `levels.js` is the pure leaf (12 levels, stars = win + par pieces + par + time, unlock = a star on the level before, the rack layout, the tower measure, the judge, the + gust schedule, the outline cells). `module.js`: ONE AUTHORITY (the physics initiator, else the + lowest peer id) deals pieces as TRANSIENT duplicates of templates parked on a vault under the + floor, judges ~10x/s, pushes gusts and writes the `tw*` game variables; every peer derives the + rest (the HUD words through the `towersinfo` value node wired into HUD Text's FORMAT, the + moments, saving stars via `api.storage`). HUD presses carry no presser, so every peer watches the + hudbutton stamps (first sight = history, `age` > 2.5 s = a joiner's history) and only the + authority acts; unlocks read the AUTHORITY's progress. `towerswobble` is a module EFFECT so the + plate is a kinematic body — a module effect's `base` is `{pos, rot: [x,y,z], scale}` ARRAYS, not + THREE objects (reading `base.rotation` throws every frame into noteFrameFailure, silently). The + shell's `api.game.levels`/`setHelp`/`onRestart` are feature-detected. The template def is the + arena + templates + HUD + a small graph; `block` (Wedge/Arch/Corner/Stairs) and `thumb.dress` + (card-only objects) are new author-kit fields. Suites play-reach-jump, towers-levels, game-towers. ## Replication golden rules diff --git a/MODULES.md b/MODULES.md index dd2c3444..74c4a7e5 100644 --- a/MODULES.md +++ b/MODULES.md @@ -747,6 +747,24 @@ data (replicated like any node edit); the slide is a pure function of `(data, time)` running on each peer — no motion messages at all. The `registerClickHandler` consumes the click so pressing doesn't select the button. +## Walkthrough: the towers module (a game's rules as a core module) + +`src/modules/towers/` runs the **Towers** template's twelve levels. It shows how a game with +real rules splits: the scene (the template) is the arena, the piece templates, the HUD and a +small graph; the module is the rules. Worth copying: + +- **The rules as a pure leaf** (`levels.js`) — the level table, the star rule, the unlocks, + the verdict stepper — tested with vitest, no browser. +- **One authority** decides anything shared (the physics initiator, else the lowest peer id): + it deals the pieces, judges the round and writes the result into game variables + (`api.game.setVar`); every peer derives its HUD words from those through a value node wired + into HUD Text's format (`api.registerValueNode`). +- **HUD buttons without a presser**: a press is a replicated stamp on a `hudbutton` node, so + every peer watches `api.flow.triggerStamp` and only the authority acts (first sight = history). +- **Progress on this device** with `api.storage` (stars, unlocks — never replicated). +- **The shell's level picker, help and restart** (`api.game.levels`, `setHelp`, `onRestart`), + feature-detected so the module also runs on an app without them. + ## Manager, dev mode & gallery (17-A2/A3) User modules install, update, disable and remove **live** — `deactivateModule` diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index 6f6be2cd..1e7b7955 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -65,7 +65,8 @@ // music {url | file, sha256, name, volume?} — the scene's background track (+ Explorer) // sounds [{key, url | file, sha256, name}] — one-shot assets for Sound nodes // view {pos, target} — the editor camera the file opens on (also the card's camera) -// thumb {camera?: , sceneGroups?: [''], +// thumb {camera?: , sceneGroups?: [''], dress?: [objects] +// (card-only objects: built for the picture, never written into the file), // toneMapping?: 'agx'|'aces'|'neutral'|'reinhard'|'cineon'|'linear'|'none'} // contest (kind contest) {brief, rules, durationDays, opensAfterDays, judging, credits} // @@ -77,6 +78,10 @@ // plane size [w, h] — faces +Z (rot [-π/2, 0, 0] to lie flat) // ring r (outer), inner (r × 0.5) — a flat annulus facing +Z // icosahedron / dodecahedron r, detail (0) +// block shape 'Wedge' | 'Arch' | 'Corner' (an L) | 'Stairs', args [w, h, d(, steps)] — the +// app's building blocks (`/create Wedge 2 1 2`), bottom-anchored (origin on the floor). +// A hull collider fits a wedge; give an arch or an L a custom compound collider +// (physics.collider 'custom' + colliderVerts/colliderPieces — the Towers def's `compound`) // light kind 'point' (default: color, intensity, distance, decay — no shadow) // | 'spot' (angle π/6, penumbra 0.3, distance, decay, target) | 'directional' // (target; its shadow frustum is FITTED to the built meshes — `fit: false` to @@ -231,247 +236,6 @@ function graphBuilder() { // literally "Build pad" must stay text, and a variable NAMED like an object is a name. const HUMAN_TEXT_KEYS = new Set(['label', 'format', 'text', 'placeholder', 'name']); -// ---- B8: Towers, the first GAME def ------------------------------------------- -// A DATA-ONLY game: core nodes + a HUD document + the collectible module. Rebuilt -// from the first playthrough's findings — the clever sensor-conveyor spawner cascaded -// once you grabbed a crate (spawn -> falls into the zone -> jitters out -> spawns -// again), the emissive shader docs read "strange", and the night look was black on the -// user's display. So: crates are PRE-PLACED dynamic objects (grabbable, stable, no -// churn — the plan's own "Towers pre-places crates"); the look is a lit preset plus -// material emissive, no shader graphs; every node carries a label; and a pause menu -// (P) gives a Restart-while-playing button. -function towersGraph() { - const g = graphBuilder(); - const { N, E } = g; - - // ---- round control --------------------------------------------------------- - // Start from the menu: entering 'playing' from menu BUMPS the round and re-stamps - // startedAt, which is what clears the perRound latches — `reset:true` would instead - // call resetGame() and force state back to MENU (it is the Back-to-menu action). - N('bstart', 'hudbutton', 'Start button', 40, 40, { element: 'start-btn' }); - N('gostart', 'setgamestate', 'Start round', 280, 40, { state: 'playing', outcome: '', reset: false }); - E('bstart', 'gostart', 'trigger'); - N('bagain', 'hudbutton', 'Play again button', 40, 190, { element: 'again-btn' }); - N('gomenu', 'setgamestate', 'Back to menu', 280, 190, { state: 'menu', outcome: '', reset: true }); - E('bagain', 'gomenu', 'trigger'); - - // ---- pause / restart while playing (P toggles a menu screen) --------------- - N('pkey', 'keypress', 'Press P', 40, 340, { code: 'KeyP', edge: 'down', pulse: 0.3 }); - N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, 340, { screen: 'pause', action: 'toggle' }); - E('pkey', 'pausetoggle', 'trigger'); - N('bresume', 'hudbutton', 'Resume button', 40, 490, { element: 'resume-btn' }); - N('resumehide', 'hudscreen', 'Close pause menu', 280, 490, { screen: 'pause', action: 'hide' }); - E('bresume', 'resumehide', 'trigger'); - // Restart while playing: to bump a FRESH round the state must ENTER 'playing' from - // elsewhere (setGameState is a no-op when already playing). So restart resets to - // menu, then a short Delay re-enters playing — that transition bumps the round and - // clears the latches. The Delay is sourced from the button (whose trigger entry - // persists), never from a Once (whose rearm would delete the entry it re-derives). - N('brestart', 'hudbutton', 'Restart button', 40, 640, { element: 'restart-btn' }); - N('restartreset', 'setgamestate', 'Restart: to menu', 280, 640, { state: 'menu', outcome: '', reset: true }); - N('restartdelay', 'delay', 'Restart: wait', 520, 640, { seconds: 0.2, pulse: 0.3 }); - N('restartplay', 'setgamestate', 'Restart: play', 760, 640, { state: 'playing', outcome: '', reset: false }); - N('restarthide', 'hudscreen', 'Close pause on restart', 280, 760, { screen: 'pause', action: 'hide' }); - E('brestart', 'restartreset', 'trigger'); - E('brestart', 'restartdelay', 'trigger'); - E('restartdelay', 'restartplay', 'trigger'); - E('brestart', 'restarthide', 'trigger'); - N('bquit', 'hudbutton', 'Quit to menu button', 40, 790, { element: 'quit-btn' }); - N('doquit', 'setgamestate', 'Quit to menu', 280, 790, { state: 'menu', outcome: '', reset: true }); - N('quithide', 'hudscreen', 'Close pause on quit', 520, 790, { screen: 'pause', action: 'hide' }); - E('bquit', 'doquit', 'trigger'); - E('bquit', 'quithide', 'trigger'); - // 30: Round over offers Play again beside Menu — the Restart chain above, from `over` - N('breplay', 'hudbutton', 'Play again button', 40, 875, { element: 'replay-btn' }); - E('breplay', 'restartreset', 'trigger'); - E('breplay', 'restartdelay', 'trigger'); - - // ---- height: rung sensors -> perRound latches -> boolean*height -> max -> HUD - for (let i = 1; i <= 4; i++) { - const y = 960 + (i - 1) * 150; - N('enr' + i, 'onenter', 'Reached ' + i + 'm', 40, y, { pulse: 0.3 }); - N('selr' + i, 'objectselector', 'Ring ' + i + 'm', 280, y, { selected: 'Height ring ' + i + 'm' }); - E('enr' + i, 'selr' + i); - N('lat' + i, 'latch', 'Held ' + i + 'm', 520, y, { initial: false, perRound: true }); - E('enr' + i, 'lat' + i, 'set'); - N('mul' + i, 'math', i + 'm value', 760, y, { op: 'mul', a: 0, b: i }); - E('lat' + i, 'mul' + i, 'a'); - } - N('mx12', 'math', 'Max 1-2m', 1000, 1000, { op: 'max', a: 0, b: 0 }); - E('mul1', 'mx12', 'a'); - E('mul2', 'mx12', 'b'); - N('mx34', 'math', 'Max 3-4m', 1000, 1200, { op: 'max', a: 0, b: 0 }); - E('mul3', 'mx34', 'a'); - E('mul4', 'mx34', 'b'); - N('mxall', 'math', 'Best height', 1240, 1100, { op: 'max', a: 0, b: 0 }); - E('mx12', 'mxall', 'a'); - E('mx34', 'mxall', 'b'); - N('hheight', 'hudtext', 'HUD height', 1480, 1030, { element: 'height-read', format: 'Best height: {v} m', decimals: 0, value: 0 }); - E('mxall', 'hheight', 'value'); - // ---- 30: the BEST HEIGHT, saved on THIS device (Store Value / Stored Value) -------- - // Stored on every ring crossing, never on `over`: a perRound latch READS un-set the - // instant the round ends (the Infinity cutoff), so an `over` edge would store 0 — and - // that is also why the old Round-over line read "0 m" every time. `towers-last` is the - // same number for THIS round: `max` too (a crate jittering in a ring sensor re-fires - // the crossing after the round ends, when the height reads 0 — a `set` would wipe it), - // zeroed by its own `set` node when a round starts. Keys are game-specific because a - // Games-tab load leaves the scene unnamed, so every template shares tp:scene:untitled:*. - N('storebest', 'storevalue', 'Save best height', 1720, 1030, { key: 'towers-best', mode: 'max', value: 0 }); - N('storelast', 'storevalue', 'Save this round', 1720, 1180, { key: 'towers-last', mode: 'max', value: 0 }); - E('mxall', 'storebest', 'value'); - E('mxall', 'storelast', 'value'); - for (let i = 1; i <= 4; i++) { - E('enr' + i, 'storebest', 'trigger'); - E('enr' + i, 'storelast', 'trigger'); - } - N('onround', 'ongamestate', 'When a round starts', 1480, 1330, { state: 'playing', edge: 'enter', pulse: 0.3 }); - N('zerolast', 'storevalue', 'New round: zero it', 1720, 1330, { key: 'towers-last', mode: 'set', value: 0 }); - E('onround', 'zerolast', 'trigger'); - N('storedbest', 'storedvalue', 'My best height', 1960, 1030, { key: 'towers-best', output: 'number', fallback: 0 }); - N('hbest', 'hudtext', 'HUD best (menu)', 2200, 980, { element: 'best-read', format: 'Your best tower: {v} m', decimals: 0, value: 0 }); - E('storedbest', 'hbest', 'value'); - N('hbest2', 'hudtext', 'HUD best (over)', 2200, 1100, { element: 'over-best', format: 'Best ever: {v} m', decimals: 0, value: 0 }); - E('storedbest', 'hbest2', 'value'); - N('storedlast', 'storedvalue', 'This round', 1960, 1180, { key: 'towers-last', output: 'number', fallback: 0 }); - N('hfinal', 'hudtext', 'HUD final height', 2200, 1220, { element: 'final-height', format: 'This round: {v} m', decimals: 0, value: 0 }); - E('storedlast', 'hfinal', 'value'); - // ---- 30b: the rings are MILESTONES. The first crossing of a ring in a round (a perRound - // Once — a crate re-entering the sensor must not re-announce) tells every player: a banner, - // a burst AT the ring, a chime from the ring, and a success buzz in VR. The gold ring at - // the top is the big one: confetti, the level-up fanfare, "Top of the tower!". The Game - // Feel nodes are LOCAL on every peer from the replicated stamp, so no message of their own. - // (It replaces 30's particle emitter on the pad: the burst pool costs no emitter slot.) - N('buzzring', 'hapticpulse', 'Buzz: a ring reached', 1720, 1480, { pattern: 'success', hand: 'both' }); - for (let i = 1; i <= 4; i++) { - const y = 960 + (i - 1) * 150; - const top = i === 4; - N('first' + i, 'once', 'First time at ' + i + 'm', 2440, y, { perRound: true, pulse: 0.3 }); - E('enr' + i, 'first' + i, 'trigger'); - N('say' + i, 'announce', top ? 'Say: top!' : 'Say: ring ' + i, 2680, y, top - ? { text: 'Top of the tower!', sub: '4 m — the gold ring', seconds: 2.6, color: '#ffc640', decimals: 0 } - : { text: 'Ring ' + i + ' reached', sub: i + ' m — keep stacking', seconds: 1.8, color: '#9ee6ff', decimals: 0 }); - E('first' + i, 'say' + i, 'trigger'); - N('fx' + i, 'effectburst', 'Burst at ring ' + i, 2920, y, { kind: top ? 'confetti' : 'sparkle', count: top ? 96 : 56, lift: 0, color: '' }); - E('first' + i, 'fx' + i, 'trigger'); - E('selr' + i, 'fx' + i, 'at'); - // an additive sparkle alone is faint against a daylight sky: a small confetti puff - // beside it reads at any exposure (the top ring's burst IS confetti already) - if (!top) { - N('puff' + i, 'effectburst', 'Puff at ring ' + i, 2920, y + 70, { kind: 'confetti', count: 36, lift: 0.1, color: '' }); - E('first' + i, 'puff' + i, 'trigger'); - E('selr' + i, 'puff' + i, 'at'); - } - N('chime' + i, 'gamesound', top ? 'Fanfare at the top' : 'Chime at ring ' + i, 3160, y, { sound: top ? 'levelup' : 'ring' }); - E('first' + i, 'chime' + i, 'trigger'); - E('selr' + i, 'chime' + i, 'at'); - E('first' + i, 'buzzring', 'trigger'); - } - N('topsparkle', 'effectburst', 'Sparkle at the top', 2920, 1560, { kind: 'sparkle', count: 80, lift: 0.4, color: '#ffe08a' }); - E('first4', 'topsparkle', 'trigger'); - E('selr4', 'topsparkle', 'at'); - - // ---- the stars — collectible-module touch pickups (shared team score) ------- - for (let i = 1; i <= 3; i++) { - const y = 1600 + (i - 1) * 150; - N('colstar' + i, 'collectible', 'Star ' + i + ' pickup', 40, y, { - variable: 'stars', scope: 'shared', trigger: 'touch', radius: 1.4, - respawn: 0, hide: 'on', perRound: true, whilePlaying: true - }); - N('selstar' + i, 'objectselector', 'Star ' + i, 280, y, { selected: 'Star ' + i }); - E('colstar' + i, 'selstar' + i); - // 30: every star turns and breathes once a round starts (its authored `Star glow` - // clip: the Turntable + Pulse presets in ONE clip, since a transport plays one clip - // per object). Through a zero-second Delay: Play Animation reads a trigger VALUE and - // On Game State carries only a stamp (the beat-graph finding). - N('glow' + i, 'playanim', 'Star ' + i + ' glow', 1240, y, { clip: 'Star glow', action: 'restart', speed: 1 }); - E('glowpulse', 'glow' + i, 'trigger'); - E('glow' + i, 'selstar' + i); - } - N('glowpulse', 'delay', 'Round start pulse (0 s)', 1000, 1560, { seconds: 0, pulse: 0.3 }); - E('onround', 'glowpulse', 'trigger'); - N('cstars', 'collectiblecount', 'Stars left', 520, 1670, { variable: 'stars', read: 'left' }); - N('hstars', 'hudtext', 'HUD stars', 760, 1670, { element: 'stars-read', format: 'Stars left: {v}', decimals: 0, value: 0 }); - E('cstars', 'hstars', 'value'); - - // ---- win: every star collected, agreed by everyone playing ----------------- - N('starsdone', 'compare', 'All stars?', 520, 1900, { op: 'lte', a: 0, b: 0 }); - E('cstars', 'starsdone', 'a'); - N('allwin', 'allplayers', 'Everyone done', 760, 1900, { pulse: 0.3 }); - E('starsdone', 'allwin', 'condition'); - N('gowin', 'setgamestate', 'Win', 1000, 1900, { state: 'over', outcome: 'All stars collected!', reset: false }); - E('allwin', 'gowin', 'trigger'); - - // ---- the round clock: ends the round on time ------------------------------- - N('clock', 'gametime', 'Time left', 40, 2100, { read: 'remaining', length: 180 }); - N('hclock', 'hudtext', 'HUD clock', 280, 2040, { element: 'clock', format: 'Time: {v}s', decimals: 0, value: 0 }); - E('clock', 'hclock', 'value'); - N('timeup', 'compare', 'Time up?', 280, 2190, { op: 'lte', a: 0, b: 0 }); - E('clock', 'timeup', 'a'); - N('playing', 'gametime', 'Is playing', 40, 2340, { read: 'playing', length: 60 }); - N('timeandplay', 'gate', 'Time up & playing', 520, 2240, { op: 'and', a: false, b: false }); - E('timeup', 'timeandplay', 'a'); - E('playing', 'timeandplay', 'b'); - N('alltime', 'allplayers', 'Everyone time up', 760, 2240, { pulse: 0.3 }); - E('timeandplay', 'alltime', 'condition'); - N('gotime', 'setgamestate', 'Time over', 1000, 2240, { state: 'over', outcome: "Time's up!", reset: false }); - E('alltime', 'gotime', 'trigger'); - - // ---- 30b: the round's sound, and the crates' ------------------------------------------ - // Music: the arcade loop while you are in the game (Interact or Play; never the editor). - N('music', 'gamemusic', 'Arcade music', 40, 2480, { preset: 'arcade', volume: 0.45, while: 'always' }); - // Every menu button clicks. - N('click', 'gamesound', 'Button click', 280, 2480, { sound: 'click' }); - for (const b of ['bstart', 'bagain', 'bresume', 'brestart', 'bquit', 'breplay']) E(b, 'click', 'trigger'); - // A round starts with a whistle and a banner. It ENDS without one: the Round over panel is - // the result (a banner over it read twice), so the end is a cheer or a whistle + confetti. - N('saygo', 'announce', 'Say: build!', 1720, 1640, { text: 'Build!', sub: 'Stack crates on the glowing pad — reach the gold ring', seconds: 2.2, color: '#ffd45e', decimals: 0 }); - E('onround', 'saygo', 'trigger'); - N('whistle', 'gamesound', 'Round start whistle', 1720, 1720, { sound: 'whistle' }); - E('onround', 'whistle', 'trigger'); - N('cheer', 'gamesound', 'Cheer', 1240, 2080, { sound: 'cheer' }); - E('allwin', 'cheer', 'trigger'); - N('confetti', 'effectburst', 'Confetti for everyone', 1240, 2160, { kind: 'confetti', count: 96, lift: 0, color: '' }); - E('allwin', 'confetti', 'trigger'); - N('upwhistle', 'gamesound', 'Final whistle', 1240, 2380, { sound: 'whistle' }); - E('alltime', 'upwhistle', 'trigger'); - // Each crate: a pop when a player lifts it (On Grab) and a knock when it lands on - // something (On Impact past a gentle landing), both placed AT the crate. - TOWERS_CRATES.forEach((name, k) => { - const y = 2560 + k * 110; - N('csel' + k, 'objectselector', name, 280, y, { selected: name }); - N('cgrab' + k, 'ongrab', name + ' grabbed', 40, y, { pulse: 0.3 }); - E('cgrab' + k, 'csel' + k); - N('cpop' + k, 'gamesound', name + ' lift', 520, y, { sound: 'pop' }); - E('cgrab' + k, 'cpop' + k, 'trigger'); - E('csel' + k, 'cpop' + k, 'at'); - N('cland' + k, 'onimpact', name + ' lands', 760, y, { pulse: 0.3, minStrength: 0.8 }); - E('cland' + k, 'csel' + k); - N('cknock' + k, 'gamesound', name + ' knock', 1000, y, { sound: 'hit' }); - E('cland' + k, 'cknock' + k, 'trigger'); - E('csel' + k, 'cknock' + k, 'at'); - }); - - return g.done(); -} -/** the nine crates the sound chains above name (the objects below) */ -const TOWERS_CRATES = ['Cube 1', 'Cube 2', 'Cube 3', 'Cube 4', 'Cube 5', 'Cube 6', 'Plank 1', 'Plank 2', 'Plank 3']; - -const TOWERS_HUD_PANEL = { - bg: 'rgba(14, 20, 32, 0.9)', - radius: 18, - border: '1px solid rgba(255, 212, 94, 0.28)' -}; -const TOWERS_BTN = { size: 17, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; -// 30: the finished look. Crates read as WOOD (a physical material with a sheen and a thin -// clearcoat, chamfered edges), the floor carries a subtle 2 m tile from a shader graph, the -// walls are chamfered with a glowing trim, and the height markers are thin glowing rings -// beside a marked pole instead of stacked translucent squares. -const TOWERS_WOOD = { physical: true, roughness: 0.6, sheen: 0.4, sheenColor: 0xffd7a0, sheenRoughness: 0.55, clearcoat: 0.12, clearcoatRoughness: 0.5 }; -const TOWERS_CRATE = { mode: 'dynamic', mass: 1, friction: 0.8, restitution: 0.03 }; -const TOWERS_PLANK = { mode: 'dynamic', mass: 0.9, friction: 0.8, restitution: 0.03 }; -/** a chamfered wooden crate (bevelSegments 1 keeps the baked geometry small) - * @param {string} name @param {number} color @param {number[]} size @param {number[]} pos @param {any} physics */ -const towersCrate = (name, color, size, pos, physics) => ({ type: 'box', name, color, size, bevel: 0.045, bevelSegments: 1, pos, ...TOWERS_WOOD, physics }); /** the floor tile: a 13 x 13 grid of 2 m tiles with a thin grout line and a faint checker, * MULTIPLYING the authored colour (albedo is `diffuseColor.rgb *= ` in the inject backend) */ const TOWERS_FLOOR_SHADER = { @@ -497,75 +261,162 @@ const TOWERS_FLOOR_SHADER = { { id: 'e-tile-s', source: 'tile', sourceHandle: 'out', target: 's', targetHandle: 'albedo' } ] }; -/** the Turntable + Pulse presets folded into ONE clip (a transport plays one clip per - * object): a turn every 6 s, a breath every 1.5 s — scale and glow together */ -const STAR_GLOW_CLIP = { - active: 'glow', - changedAt: 0, - clips: { - glow: { - name: 'Star glow', - duration: 6, - loop: 'loop', - tracks: [ - { id: 'turn', channel: 'rot.y', keys: [{ t: 0, v: 0 }, { t: 6, v: 6.2832 }] }, - { - id: 'breathe', - channel: 'scale', - keys: [0, 1.5, 3, 4.5, 6].flatMap((t, i, all) => - i < all.length - 1 - ? [{ t, v: 1, ease: [0.42, 0, 0.58, 1] }, { t: t + 0.75, v: 1.18, ease: [0.42, 0, 0.58, 1] }] - : [{ t, v: 1 }] - ) - }, - { - id: 'shine', - channel: 'emissive', - keys: [0, 1.5, 3, 4.5, 6].flatMap((t, i, all) => - i < all.length - 1 ? [{ t, v: 1.6 }, { t: t + 0.75, v: 3.2 }] : [{ t, v: 1.6 }] - ) - } - ] - } + +// ---- B8 → 31-towers: Towers, a real game ---------------------------------------- +// The round was "stack crates to the gold ring, hooray" (the Quest note: "after finishing +// basic level nothing happens"). It is TWELVE LEVELS now, each with its own piece shapes, a +// limited supply dealt onto racks, a goal, par pieces and par time for 1-3 stars, an unlock +// chain and logical rules: grab REACH is limited (play.reach, 1.3 m from your body), so the +// high levels need steps and a JUMP (the Character Controller's jumpHeight, desktop Space / +// VR A); pieces dropped outside the build yard are LOST; later levels add wind, a narrow +// pedestal, a rocking plate, an outline to fill and a star to deliver. +// +// THE SPLIT: this def is the ARENA (every object the levels name — zones, racks, the ledge, +// the perch, the piece TEMPLATES under the floor, the goal/yard/ghost markers), the HUD and a +// small graph (buttons, P pause, click sounds, music, the controller, the wobble effect, and +// the HUD words through the core `towers` module's `towersinfo` value node). The levels, the +// dealing, the judge and the stars live in src/modules/towers (levels.js is the table). +// No module download: the template needs only core. + +/** one wooden look for every piece */ +const TOWERS_WOOD = { physical: true, roughness: 0.6, sheen: 0.4, sheenColor: 0xffd7a0, sheenRoughness: 0.55, clearcoat: 0.12, clearcoatRoughness: 0.5 }; +/** the eight corners of a local box, flat — a custom compound collider piece + * @param {number} x0 @param {number} x1 @param {number} y0 @param {number} y1 @param {number} z0 @param {number} z1 */ +const boxVerts = (x0, x1, y0, y1, z0, z1) => { + const out = []; + for (const x of [x0, x1]) for (const y of [y0, y1]) for (const z of [z0, z1]) out.push(x, y, z); + return out; +}; +/** a custom collider from box pieces @param {number[][]} boxes [x0,x1,y0,y1,z0,z1] each */ +const compound = (boxes) => { + const colliderVerts = []; + const colliderPieces = []; + for (const b of boxes) { + colliderPieces.push([colliderVerts.length, 24]); + colliderVerts.push(...boxVerts(...b)); } + return { collider: 'custom', colliderVerts, colliderPieces }; +}; +const piecePhysics = (mass, extra = {}) => ({ mode: 'dynamic', mass, friction: 0.85, restitution: 0.03, ...extra }); +/** the vault the templates rest on, far under the arena floor */ +const VAULT_TOP = -6.25; +/** the piece TEMPLATES: dynamic (so every copy is), parked on the vault; the module copies + * them as transient pieces. Names are the module's `SHAPES[*].template`. */ +const TOWERS_TEMPLATES = [ + { type: 'box', name: 'Piece cube', color: 0xb57a45, size: [0.6, 0.6, 0.6], bevel: 0.045, bevelSegments: 1, pos: [-6, VAULT_TOP + 0.3, 0], ...TOWERS_WOOD, physics: piecePhysics(1) }, + { type: 'box', name: 'Piece plank', color: 0xd2a86e, size: [1.4, 0.3, 0.6], bevel: 0.04, bevelSegments: 1, pos: [-4.5, VAULT_TOP + 0.15, 0], ...TOWERS_WOOD, physics: piecePhysics(0.9) }, + { type: 'box', name: 'Piece beam', color: 0xc49a60, size: [2.0, 0.25, 0.4], bevel: 0.035, bevelSegments: 1, pos: [-2.4, VAULT_TOP + 0.125, 0], ...TOWERS_WOOD, physics: piecePhysics(1) }, + { type: 'block', shape: 'Wedge', args: [0.8, 0.6, 0.6], name: 'Piece wedge', color: 0x5f9fd8, pos: [-0.6, VAULT_TOP, 0], roughness: 0.55, physics: piecePhysics(0.7, { collider: 'hull' }) }, + { type: 'cylinder', name: 'Piece barrel', color: 0x9a5b2e, r: 0.3, h: 0.7, pos: [0.6, VAULT_TOP + 0.35, 0], ...TOWERS_WOOD, physics: piecePhysics(0.8, { collider: 'cylinder' }) }, + { + type: 'block', shape: 'Arch', args: [1.2, 0.8, 0.5], name: 'Piece arch', color: 0xb8ad98, pos: [2.2, VAULT_TOP, 0], roughness: 0.8, + physics: piecePhysics(1.4, compound([[-0.6, -0.36, 0, 0.8, -0.25, 0.25], [0.36, 0.6, 0, 0.8, -0.25, 0.25], [-0.36, 0.36, 0.72, 0.8, -0.25, 0.25]])) + }, + { + type: 'block', shape: 'Corner', args: [0.9, 0.45, 0.3], name: 'Piece L', color: 0x86b865, pos: [3.8, VAULT_TOP, 0], roughness: 0.6, + physics: piecePhysics(1, compound([[-0.45, 0.45, 0, 0.45, 0.15, 0.45], [-0.45, -0.15, 0, 0.45, -0.45, 0.45]])) + }, + { type: 'sphere', name: 'Piece ball', color: 0xe0604a, r: 0.3, pos: [5.2, VAULT_TOP + 0.3, 0], physical: true, roughness: 0.35, clearcoat: 0.6, physics: piecePhysics(0.8, { collider: 'sphere', friction: 0.6, restitution: 0.15 }) }, + { type: 'box', name: 'Piece base', color: 0x59616e, size: [1.4, 0.35, 1.4], bevel: 0.05, bevelSegments: 1, pos: [6.8, VAULT_TOP + 0.175, 0], physical: true, roughness: 0.7, clearcoat: 0.2, physics: piecePhysics(6, { friction: 1 }) }, + { type: 'dodecahedron', name: 'Piece star', color: 0xffc640, r: 0.26, pos: [8.4, VAULT_TOP + 0.26, 0], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true, physics: piecePhysics(0.4, { collider: 'hull' }) } +]; + +const TOWERS_HUD_PANEL = { bg: 'rgba(14, 20, 32, 0.9)', radius: 18, border: '1px solid rgba(255, 212, 94, 0.28)' }; +const TOWERS_BTN = { size: 17, weight: '600', bg: '#3b7dd8', color: '#ffffff', radius: 10 }; +const TOWERS_LEVEL_NAMES = ['Stack', 'Planks', 'Climb', 'Wedges', 'Barrels', 'Arches', 'Narrow base', 'Gusts', 'Balls', 'Wobble', 'Outline', 'Summit']; + +function towersGraph() { + const g = graphBuilder(); + const { N, E } = g; + // ---- the buttons: the core `towers` module watches their stamps; the graph adds the click + N('click', 'gamesound', 'Button click', 520, 40, { sound: 'click' }); + const button = (id, element, label, x, y) => { + N(id, 'hudbutton', label, x, y, { element }); + E(id, 'click', 'trigger'); + return id; + }; + for (let i = 1; i <= 12; i++) button('lvl' + i, 'lvl-' + i, 'Level ' + i + ' button', 40, 40 + (i - 1) * 70); + button('bnext', 'next-btn', 'Next level button', 40, 900); + button('bretry', 'retry-btn', 'Retry button', 40, 970); + button('blevels', 'levels-btn', 'Levels button', 40, 1040); + // ---- pause (P): Resume closes it; Restart and Levels are the module's, and close it too + N('pkey', 'keypress', 'Press P', 40, 1140, { code: 'KeyP', edge: 'down', pulse: 0.3 }); + N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, 1140, { screen: 'pause', action: 'toggle' }); + E('pkey', 'pausetoggle', 'trigger'); + N('pausehide', 'hudscreen', 'Close pause menu', 520, 1240, { screen: 'pause', action: 'hide' }); + button('bresume', 'resume-btn', 'Resume button', 40, 1210); + button('brestart', 'restart-btn', 'Restart level button', 40, 1280); + button('bquit', 'quit-btn', 'Levels (pause) button', 40, 1350); + for (const b of ['bresume', 'brestart', 'bquit']) E(b, 'pausehide', 'trigger'); + // ---- the HUD's words, from the module's Towers info node into HUD Text's FORMAT + const text = (id, read, element, x, y, extra = {}) => { + N(id + 'i', 'towersinfo', 'Towers: ' + read, x, y, { read, ...extra }); + N(id + 't', 'hudtext', 'HUD ' + element, x + 240, y, { element, format: '', decimals: 0, value: 0 }); + E(id + 'i', id + 't', 'format'); + }; + text('mline', 'menuLine', 'menu-line', 800, 40); + for (let i = 1; i <= 12; i++) text('ls' + i, 'levelStars', 'lvl-' + i + '-stars', 800, 110 + (i - 1) * 70, { level: i }); + text('title', 'title', 'tw-title', 1300, 40); + text('goal', 'goalText', 'tw-goal', 1300, 110); + text('pieces', 'pieces', 'tw-pieces', 1300, 180); + text('clock', 'clock', 'tw-clock', 1300, 250); + text('hold', 'hold', 'tw-hold', 1300, 320); + text('rule', 'rule', 'tw-rule', 1300, 390); + text('result', 'result', 'tw-result', 1300, 480); + text('rstars', 'resultStars', 'tw-stars', 1300, 550); + text('rline', 'resultLine', 'tw-line', 1300, 620); + text('rbest', 'resultBest', 'tw-best', 1300, 690); + N('progi', 'towersinfo', 'Towers: progress', 1300, 780, { read: 'progress' }); + N('progbar', 'hudbar', 'HUD progress bar', 1540, 780, { element: 'tw-bar', value: 0, min: 0, max: 1, format: '' }); + E('progi', 'progbar', 'value'); + // ---- the world: music in the game, the player's body, the rocking plate + N('music', 'gamemusic', 'Arcade music', 40, 1460, { preset: 'arcade', volume: 0.4, while: 'always' }); + // the Character Controller: WALK with gravity and a 1 m jump (Space on a desktop, A in VR), + // which is what lets a player climb onto pieces to reach a high goal + N('body', 'charcontroller', 'Player: walk + jump', 280, 1460, { mode: 'walk', speed: 0.06, jumpHeight: 1.0, eyeHeight: 1.7, gravity: true }); + N('wobble', 'towerswobble', 'Wobble plate rocks (level 10)', 40, 1560, { amplitude: 0.07, period: 3.4 }); + N('selwobble', 'objectselector', 'Wobble plate', 280, 1560, { selected: 'Wobble plate' }); + E('wobble', 'selwobble'); + return g.done(); +} + +/** a level-select cell: the button and the stars line under it @param {number} i 1..12 */ +const towersLevelCell = (i) => { + const col = (i - 1) % 4; + const row = Math.floor((i - 1) / 4); + const x = -216 + col * 144; + const y = -40 + row * 76; + return [ + { id: 'lvl-' + i, kind: 'button', anchor: 'center', x, y, w: 132, h: 42, z: 1, label: i + ' · ' + TOWERS_LEVEL_NAMES[i - 1], enabled: true, style: { ...TOWERS_BTN, size: 14 } }, + { id: 'lvl-' + i + '-stars', kind: 'text', anchor: 'center', x, y: y + 30, w: 132, h: 18, z: 1, label: '', style: { size: 13, weight: '600', color: '#ffd45e', align: 'center' } } + ]; }; + const TOWERS_DEF = { kind: 'game', slug: 'towers', title: 'Towers', description: - 'Co-op crate stacking: grab the wooden crates, build the tallest tower on the glowing pad, climb to the stars. Your best height is saved. Press P to pause or restart.', + 'Twelve levels of stacking: cubes, planks, wedges, barrels, arches and balls. You can only reach what is close to you, so build steps and jump. Wind, a wobbling plate and a star to deliver. 1-3 stars a level, saved on this device.', license: 'CC0-1.0', author: 'theprototype', - tags: ['physics', 'stacking', 'co-op', 'vr'], - modules: [{ id: 'collectible', version: '1.1.2' }], - installModules: ['collectible'], - // 30: daylight under a real sky — a blue-to-haze gradient, a far fog that softens the - // horizon, and a solid ground disc around the arena (the infinite grid is editor chrome; - // it no longer shows in Play). Exposure above the 0.9 floor. + tags: ['physics', 'stacking', 'puzzle', 'co-op', 'vr'], + modules: [], env: { preset: 'daylight', exposure: 1.05, background: { top: '#4a7fc0', bottom: '#dbe8f2' }, - // 30 integrate: the fog closes in past the arena wall, so the ground beyond the edge - // fades into the sky's own haze instead of ending in a flat olive band fog: { color: '#dbe8f2', near: 16, far: 75 }, ground: { color: '#7b8866', roughness: 0.95 } }, - // ground ON — a solid floor the crates rest on. A crate knocked past the low wall - // falls to the bounds limit and RESPAWNS to its start pose (beforeStates), so the - // supply cannot be lost. Grab interaction, sim starts on Play. physics: { ground: { enabled: true, height: 0, friction: 0.8, restitution: 0 }, bounds: { limit: -20, action: 'respawn' }, material: { friction: 0.7, restitution: 0.05 }, damping: { linear: 0.05, angular: 0.3 }, - // 30b: a SPAWN — Play (and Interact in VR) puts you 4.5 m in front of the pad, facing - // it, with a podium on each hand (yaw 0 faces -Z) - play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 4.5], yaw: 0 } } + // 31-towers: REACH 1.3 m from your body — the puzzle's first rule + play: { interaction: 'grab', grounded: false, simOnPlay: true, spawn: { position: [0, 0, 5.4], yaw: 0 }, reach: 1.3 } }, - // the shell floor (fork 11): ao -> AgX -> bloom -> smaa, bloom a touch higher so the - // rings, the trim and the pad read as light post: { enabled: true, effects: [ @@ -576,11 +427,28 @@ const TOWERS_DEF = { ], changedAt: 0 }, - view: { pos: [7.5, 6.5, 12.5], target: [0, 1.4, 0] }, - thumb: { camera: 'Card camera' }, + view: { pos: [9, 7.5, 13.5], target: [0, 1.4, -1] }, + thumb: { + camera: 'Card camera', + // a round in progress: a half-built tower on the pad, the supply on the racks + dress: [ + { ...TOWERS_TEMPLATES[8], name: 'Card base', pos: [0, 0.375, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card cube 1', pos: [-0.35, 0.86, 0.1], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card cube 2', pos: [0.35, 0.86, -0.1], rot: [0, 0.3, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[1], name: 'Card plank', pos: [0, 1.32, 0], rot: [0, 0.4, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[3], name: 'Card wedge', pos: [0, 1.47, 0], rot: [0, 0.4, 0], physics: undefined }, + { ...TOWERS_TEMPLATES[4], name: 'Card barrel', pos: [0.9, 0.35, 1.9], rot: [0, 0, Math.PI / 2], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card rack 1', pos: [4.4, 1.12, 0.9], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card rack 2', pos: [4.4, 1.12, 1.7], physics: undefined }, + { ...TOWERS_TEMPLATES[5], name: 'Card rack 3', pos: [4.4, 0.82, 2.8], physics: undefined }, + { ...TOWERS_TEMPLATES[1], name: 'Card rack 4', pos: [-4.4, 0.97, 1.0], physics: undefined }, + { ...TOWERS_TEMPLATES[6], name: 'Card rack 5', pos: [-4.4, 0.82, 2.4], physics: undefined }, + { ...TOWERS_TEMPLATES[0], name: 'Card ledge', pos: [0.6, 3.32, -3.0], physics: undefined }, + { ...TOWERS_TEMPLATES[9], name: 'Card star', pos: [2.4, 3.68, -2.2], physics: undefined } + ] + }, graphs: { scene: towersGraph() }, shaders: { 'Arena floor': TOWERS_FLOOR_SHADER }, - animations: { 'Star 1': STAR_GLOW_CLIP, 'Star 2': STAR_GLOW_CLIP, 'Star 3': STAR_GLOW_CLIP }, hud: { scene: { active: '', @@ -588,19 +456,17 @@ const TOWERS_DEF = { screens: [ { id: 'menu', - name: 'Menu', + name: 'Levels', showWhile: 'menu', input: 'menu', elements: [ - { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 500, h: 460, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -176, w: 420, h: 54, z: 1, label: 'TOWERS', style: { size: 42, weight: '700', color: '#ffd45e', align: 'center' } }, - // 30b: HOW TO PLAY, said once, plainly — the goal, the milestones, the clock - { id: 'howto-title', kind: 'text', anchor: 'center', x: 0, y: -132, w: 440, h: 20, z: 1, label: 'HOW TO PLAY', style: { size: 12, weight: '700', color: '#9ee6ff', align: 'center' } }, - { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -94, w: 450, h: 60, z: 1, label: 'Grab crates from the podiums and stack them on the glowing pad. Each ring your tower reaches is a milestone — the gold ring at 4 m is the top. Touch the three floating stars. Three minutes a round.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true }, - { id: 'best-read', kind: 'text', anchor: 'center', x: 0, y: -44, w: 420, h: 24, z: 1, label: 'Your best tower: 0 m', style: { size: 15, weight: '600', color: '#9ee6ff', align: 'center' } }, - { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 14, w: 240, h: 50, z: 1, label: 'Start round', enabled: true, style: TOWERS_BTN }, - { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 88, w: 460, h: 36, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · Q/E fly · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, - { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 128, w: 460, h: 36, z: 1, label: 'VR: grip grabs a crate · left stick walks · Y switches to Edit mode', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } + { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 20, w: 640, h: 520, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -206, w: 520, h: 50, z: 1, label: 'TOWERS', style: { size: 40, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -154, w: 580, h: 44, z: 1, label: 'Carry pieces from the racks to the glowing zone and build up to the gold ring. You can only grab what is close to you, so build steps and jump to reach higher. The tower must hold still for three seconds.', style: { size: 13, color: '#d8dee9', align: 'center' }, wrap: true }, + { id: 'menu-line', kind: 'text', anchor: 'center', x: 0, y: -110, w: 420, h: 22, z: 1, label: '', style: { size: 14, weight: '600', color: '#9ee6ff', align: 'center' } }, + ...Array.from({ length: 12 }, (_, k) => towersLevelCell(k + 1)).flat(), + { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 214, w: 600, h: 34, z: 1, label: 'Desktop: hold click to grab · wheel pushes/pulls · WASD walk · Space jumps · P pause', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }, + { id: 'menu-hint-vr', kind: 'text', anchor: 'center', x: 0, y: 244, w: 600, h: 34, z: 1, label: 'VR: grip grabs a piece · left stick walks · A jumps · Y switches to Edit', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true } ] }, { @@ -609,10 +475,13 @@ const TOWERS_DEF = { showWhile: 'playing', input: 'game', elements: [ - { id: 'height-read', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, z: 1, label: '', style: { size: 20, weight: '700', color: '#ffffff', align: 'center' } }, - { id: 'clock', kind: 'text', anchor: 'top-center', x: 0, y: 48, w: 120, h: 22, z: 1, label: '', style: { size: 13, weight: '600', color: '#e5e9f0', align: 'center' } }, - { id: 'stars-read', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 200, h: 24, z: 1, label: '', style: { size: 15, weight: '600', color: '#ffd45e', align: 'right' } }, - { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Stack on the glowing pad — the rings mark your height. Press P to pause.', style: { size: 11, color: '#e5e9f0', align: 'center' } } + { id: 'tw-title', kind: 'text', anchor: 'top-center', x: 0, y: 12, w: 360, h: 28, z: 1, label: '', style: { size: 19, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-goal', kind: 'text', anchor: 'top-center', x: 0, y: 42, w: 420, h: 22, z: 1, label: '', style: { size: 14, weight: '600', color: '#ffffff', align: 'center' } }, + { id: 'tw-bar', kind: 'bar', anchor: 'top-center', x: 0, y: 68, w: 300, h: 10, z: 1, label: '', value: 0, min: 0, max: 1, style: { color: '#ffd45e', bg: 'rgba(255,255,255,0.18)', radius: 5 } }, + { id: 'tw-clock', kind: 'text', anchor: 'top-right', x: 16, y: 14, w: 120, h: 26, z: 1, label: '', style: { size: 20, weight: '700', color: '#e5e9f0', align: 'right' } }, + { id: 'tw-pieces', kind: 'text', anchor: 'top-right', x: 16, y: 44, w: 320, h: 20, z: 1, label: '', style: { size: 12, weight: '600', color: '#9ee6ff', align: 'right' } }, + { id: 'tw-hold', kind: 'text', anchor: 'center', x: 0, y: -120, w: 360, h: 40, z: 1, label: '', style: { size: 28, weight: '700', color: '#7dffb0', align: 'center' } }, + { id: 'tw-rule', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 620, h: 20, z: 1, label: '', style: { size: 12, color: '#e5e9f0', align: 'center' } } ] }, { @@ -623,28 +492,32 @@ const TOWERS_DEF = { { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 380, h: 300, z: 0, label: '', style: TOWERS_HUD_PANEL }, { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -95, w: 340, h: 36, z: 1, label: 'PAUSED', style: { size: 26, weight: '700', color: '#e5e9f0', align: 'center' } }, { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: -30, w: 240, h: 42, z: 1, label: 'Resume', enabled: true, style: { ...TOWERS_BTN, size: 16 } }, - { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 22, w: 240, h: 42, z: 1, label: 'Restart round', enabled: true, style: { ...TOWERS_BTN, size: 16, bg: '#4c9e6a' } }, - { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 74, w: 240, h: 42, z: 1, label: 'Quit to menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } + { id: 'restart-btn', kind: 'button', anchor: 'center', x: 0, y: 22, w: 240, h: 42, z: 1, label: 'Restart level', enabled: true, style: { ...TOWERS_BTN, size: 16, bg: '#4c9e6a' } }, + { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 74, w: 240, h: 42, z: 1, label: 'Levels', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } ] }, { id: 'over', - name: 'Round over', + name: 'Results', showWhile: 'over', input: 'menu', elements: [ - { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 440, h: 320, z: 0, label: '', style: TOWERS_HUD_PANEL }, - { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -104, w: 400, h: 40, z: 1, label: 'ROUND OVER', style: { size: 30, weight: '700', color: '#ffd45e', align: 'center' } }, - { id: 'final-height', kind: 'text', anchor: 'center', x: 0, y: -56, w: 400, h: 28, z: 1, label: '', style: { size: 18, weight: '600', color: '#e5e9f0', align: 'center' } }, - { id: 'over-best', kind: 'text', anchor: 'center', x: 0, y: -24, w: 400, h: 22, z: 1, label: '', style: { size: 14, color: '#9ee6ff', align: 'center' } }, - { id: 'replay-btn', kind: 'button', anchor: 'center', x: 0, y: 40, w: 240, h: 46, z: 1, label: 'Play again', enabled: true, style: TOWERS_BTN }, - { id: 'again-btn', kind: 'button', anchor: 'center', x: 0, y: 96, w: 240, h: 42, z: 1, label: 'Menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } + { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 360, z: 0, label: '', style: TOWERS_HUD_PANEL }, + { id: 'tw-result', kind: 'text', anchor: 'center', x: 0, y: -130, w: 440, h: 40, z: 1, label: '', style: { size: 28, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-stars', kind: 'text', anchor: 'center', x: 0, y: -84, w: 300, h: 46, z: 1, label: '', style: { size: 38, weight: '700', color: '#ffd45e', align: 'center' } }, + { id: 'tw-line', kind: 'text', anchor: 'center', x: 0, y: -36, w: 440, h: 40, z: 1, label: '', style: { size: 13, color: '#e5e9f0', align: 'center' }, wrap: true }, + { id: 'tw-best', kind: 'text', anchor: 'center', x: 0, y: -4, w: 440, h: 20, z: 1, label: '', style: { size: 12, color: '#9ee6ff', align: 'center' } }, + { id: 'next-btn', kind: 'button', anchor: 'center', x: 0, y: 50, w: 240, h: 46, z: 1, label: 'Next level', enabled: true, style: TOWERS_BTN }, + { id: 'retry-btn', kind: 'button', anchor: 'center', x: -64, y: 110, w: 116, h: 40, z: 1, label: 'Retry', enabled: true, style: { ...TOWERS_BTN, size: 15, bg: '#4c9e6a' } }, + { id: 'levels-btn', kind: 'button', anchor: 'center', x: 64, y: 110, w: 116, h: 40, z: 1, label: 'Levels', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } } ] } ] } }, objects: [ + // the marker that wakes the core `towers` module (an empty: no body, no draw) + { type: 'empty', name: 'Towers game' }, // the arena — a tiled floor with a low chamfered rim and a glowing trim on top { type: 'box', name: 'Arena floor', color: 0xb4bbc4, size: [26, 0.5, 26], pos: [0, -0.25, 0], roughness: 0.78, physics: { mode: 'static', friction: 0.9 } }, { type: 'box', name: 'Wall north', color: 0x7a8494, size: [26, 1, 0.5], bevel: 0.1, bevelSegments: 1, pos: [0, 0.5, -13], physical: true, roughness: 0.55, clearcoat: 0.3, physics: { mode: 'static' } }, @@ -655,46 +528,38 @@ const TOWERS_DEF = { { type: 'box', name: 'Trim south', color: 0x9fe8ff, size: [25.4, 0.05, 0.12], pos: [0, 1.02, 13], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, { type: 'box', name: 'Trim west', color: 0x9fe8ff, size: [0.12, 0.05, 25.4], pos: [-13, 1.02, 0], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, { type: 'box', name: 'Trim east', color: 0x9fe8ff, size: [0.12, 0.05, 25.4], pos: [13, 1.02, 0], emissive: 0x4fcfff, emissiveIntensity: 2.4, shadow: false }, - // build pad — glowing blue under a clearcoat, SUNK so its bottom face is not coplanar - // with the floor, a bright rim, and a soft blue light spilling onto the tiles + // ZONE 1 — the build pad (most levels), sunk so its bottom is not coplanar with the floor { type: 'cylinder', name: 'Build pad', color: 0x2f6fbf, r: 1.7, h: 0.24, pos: [0, 0.08, 0], emissive: 0x2f8fff, emissiveIntensity: 1.1, physical: true, roughness: 0.3, clearcoat: 0.8, physics: { mode: 'static', friction: 1 } }, - { type: 'torus', name: 'Pad rim', color: 0xbfefff, r: 1.72, tube: 0.045, pos: [0, 0.2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x7fdcff, emissiveIntensity: 3, shadow: false }, + { type: 'torus', name: 'Pad rim', color: 0xbfefff, r: 1.72, tube: 0.045, pos: [0, 0.2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x7fdcff, emissiveIntensity: 3, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, { type: 'light', name: 'Pad glow', kind: 'point', color: 0x5fb4ff, intensity: 4, distance: 7, pos: [0, 0.7, 0] }, - // podiums where the crate supply sits, sunk into the floor by the same trick - { type: 'cylinder', name: 'Cube podium', color: 0x5d6879, r: 1.1, h: 0.5, pos: [-5.5, 0.2, 0], physical: true, metalness: 0.1, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 0.9 } }, - { type: 'cylinder', name: 'Plank podium', color: 0x5d6879, r: 1.1, h: 0.5, pos: [5.5, 0.2, 0], physical: true, metalness: 0.1, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 0.9 } }, - // PRE-PLACED wooden crates: a tidy supply that rests until grabbed, then stays put. - // Cubes on the left podium (podium top ~0.45; stack from just above it). - towersCrate('Cube 1', 0xb57a45, [0.6, 0.6, 0.6], [-5.5, 0.85, 0], TOWERS_CRATE), - towersCrate('Cube 2', 0xc28d55, [0.6, 0.6, 0.6], [-5.5, 1.5, 0], TOWERS_CRATE), - towersCrate('Cube 3', 0xa86d3c, [0.6, 0.6, 0.6], [-5.5, 2.15, 0], TOWERS_CRATE), - towersCrate('Cube 4', 0xb57a45, [0.6, 0.6, 0.6], [-5.5, 2.8, 0], TOWERS_CRATE), - // planks on the right podium - towersCrate('Plank 1', 0xd2a86e, [1.4, 0.3, 0.6], [5.5, 0.75, 0], TOWERS_PLANK), - towersCrate('Plank 2', 0xc49a60, [1.4, 0.3, 0.6], [5.5, 1.2, 0], TOWERS_PLANK), - towersCrate('Plank 3', 0xd2a86e, [1.4, 0.3, 0.6], [5.5, 1.65, 0], TOWERS_PLANK), - // a few loose cubes near the pad to start building right away - towersCrate('Cube 5', 0xc28d55, [0.6, 0.6, 0.6], [-2, 0.35, 2], TOWERS_CRATE), - towersCrate('Cube 6', 0xa86d3c, [0.6, 0.6, 0.6], [2, 0.35, 2], TOWERS_CRATE), - // height rings over the pad — thin glowing rings a rising crate trips (the sensor is - // the ring's box, so the hole still counts); select-through, so a click reaches the - // tower inside. The top ring is gold: the one worth a sparkle. - { type: 'torus', name: 'Height ring 1m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 1, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 2m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 3m', color: 0xbff4ff, r: 0.9, tube: 0.035, pos: [0, 3, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.2, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - { type: 'torus', name: 'Height ring 4m', color: 0xfff0b8, r: 0.9, tube: 0.04, pos: [0, 4, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.6, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true, collider: 'box' } }, - // the measuring pole beside the pad: one bright mark per metre, lined up with a ring - { type: 'cylinder', name: 'Height pole', color: 0xe8edf3, r: 0.04, h: 4.3, pos: [2.2, 2.15, 0], physical: true, metalness: 0.6, roughness: 0.3, shadow: false }, - { type: 'torus', name: 'Pole mark 1m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 1, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 2m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 2, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 3m', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, 3, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false }, - { type: 'torus', name: 'Pole mark 4m', color: 0xfff0b8, r: 0.1, tube: 0.03, pos: [2.2, 4, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.6, shadow: false }, - // the stars — faceted glowing collectibles at climbing heights (touch pickups) - { type: 'dodecahedron', name: 'Star 1', color: 0xffc640, r: 0.24, pos: [-3.5, 2.4, 3.5], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - { type: 'dodecahedron', name: 'Star 2', color: 0xffc640, r: 0.24, pos: [3.5, 3.2, -3.5], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - { type: 'dodecahedron', name: 'Star 3', color: 0xffc640, r: 0.24, pos: [0, 4.6, 0], emissive: 0xffb830, emissiveIntensity: 1.6, physical: true, roughness: 0.25, metalness: 0.1, clearcoat: 1, flatShading: true }, - // the card's camera: a 3/4 view over the pad with the podiums either side - { type: 'camera', name: 'Card camera', pos: [8.2, 6, 10.2], lookAt: [0, 1.5, 0], fov: 45 } + // ZONE 2 — the narrow pedestal (level 7), north-west + { type: 'box', name: 'Pedestal', color: 0x6a7382, size: [0.8, 0.8, 0.8], bevel: 0.05, bevelSegments: 1, pos: [-6, 0.4, -4.5], physical: true, roughness: 0.5, clearcoat: 0.4, physics: { mode: 'static', friction: 1 } }, + { type: 'box', name: 'Pedestal cap', color: 0x9fe8ff, size: [0.84, 0.03, 0.84], pos: [-6, 0.815, -4.5], emissive: 0x4fcfff, emissiveIntensity: 1.6, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + // ZONE 3 — the wobble plate (level 10), north-east: it ROCKS through the module's effect + { type: 'cone', name: 'Wobble pivot', color: 0x59616e, r: 0.5, h: 0.2, pos: [6, 0.1, -4.5], roughness: 0.5, physics: { mode: 'static' } }, + { type: 'box', name: 'Wobble plate', color: 0x3d7f6e, size: [2.2, 0.2, 2.2], bevel: 0.05, bevelSegments: 1, pos: [6, 0.25, -4.5], emissive: 0x2a8f6e, emissiveIntensity: 0.5, physical: true, roughness: 0.4, clearcoat: 0.5, physics: { mode: 'static', friction: 1 } }, + // the SUPPLY: two low racks either side of the spawn (top 0.8), a high ledge north of the + // pad (top 3.0 — out of reach from the floor) and a star perch (top 3.4) + { type: 'box', name: 'Rack west', color: 0x6b5a48, size: [1.6, 0.8, 4], bevel: 0.05, bevelSegments: 1, pos: [-4.4, 0.4, 2.2], ...TOWERS_WOOD, physics: { mode: 'static', friction: 0.9 } }, + { type: 'box', name: 'Rack east', color: 0x6b5a48, size: [1.6, 0.8, 4], bevel: 0.05, bevelSegments: 1, pos: [4.4, 0.4, 2.2], ...TOWERS_WOOD, physics: { mode: 'static', friction: 0.9 } }, + { type: 'box', name: 'High ledge', color: 0x7a8494, size: [3, 0.3, 0.9], bevel: 0.05, bevelSegments: 1, pos: [0, 2.85, -3.0], physical: true, roughness: 0.5, clearcoat: 0.3, physics: { mode: 'static', friction: 0.9 } }, + { type: 'cylinder', name: 'Ledge post west', color: 0x5d6879, r: 0.09, h: 2.7, pos: [-1.35, 1.35, -3.0], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'cylinder', name: 'Ledge post east', color: 0x5d6879, r: 0.09, h: 2.7, pos: [1.35, 1.35, -3.0], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + { type: 'box', name: 'Star perch', color: 0x7a8494, size: [0.7, 0.2, 0.7], bevel: 0.04, bevelSegments: 1, pos: [2.4, 3.3, -2.2], physical: true, roughness: 0.5, physics: { mode: 'static', friction: 1 } }, + { type: 'cylinder', name: 'Perch post', color: 0x5d6879, r: 0.06, h: 3.2, pos: [2.4, 1.6, -2.2], physical: true, metalness: 0.3, roughness: 0.4, physics: { mode: 'static' } }, + // the MOVING markers — the module puts them on the level's zone (a sensor each, so none + // is a wall: every top-level object becomes a fixed body at sim start) + { type: 'torus', name: 'Goal ring', color: 0xfff0b8, r: 0.95, tube: 0.045, pos: [0, 1.8, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0xffc640, emissiveIntensity: 2.8, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + { type: 'ring', name: 'Yard ring', color: 0xbff4ff, r: 3.4, inner: 3.3, pos: [0, 0.02, 0], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 1.2, shadow: false, side: 'double', pick: 'through', physics: { mode: 'static', sensor: true } }, + { type: 'box', name: 'Ghost wall', color: 0x9fe8ff, size: [1.8, 1.2, 0.62], pos: [0, -9, 0], emissive: 0x49d2ff, emissiveIntensity: 0.9, opacity: 0.22, shadow: false, pick: 'through', physics: { mode: 'static', sensor: true } }, + // the measuring pole beside the pad: a bright mark per metre + { type: 'cylinder', name: 'Height pole', color: 0xe8edf3, r: 0.04, h: 4.3, pos: [2.2, 2.15, 0.6], physical: true, metalness: 0.6, roughness: 0.3, shadow: false, physics: { mode: 'static' } }, + ...[1, 2, 3, 4].map((m) => ({ type: 'torus', name: 'Pole mark ' + m + 'm', color: 0xbff4ff, r: 0.1, tube: 0.025, pos: [2.2, m, 0.6], rot: [-Math.PI / 2, 0, 0], emissive: 0x49d2ff, emissiveIntensity: 2.4, shadow: false, physics: { mode: 'static', sensor: true } })), + // the piece TEMPLATES, parked on a vault slab far under the floor + { type: 'box', name: 'Template vault', color: 0x333333, size: [18, 0.5, 3], pos: [1, VAULT_TOP - 0.25, 0], shadow: false, physics: { mode: 'static', friction: 1 } }, + ...TOWERS_TEMPLATES, + // the card's camera: a 3/4 view over the pad, the ledge and a rack + { type: 'camera', name: 'Card camera', pos: [6.6, 4.9, 8.2], lookAt: [0, 1.3, -0.9], fov: 45 } ] }; @@ -2374,6 +2239,11 @@ const DEFS = [ // reflection flips handedness, and every primitive here is symmetric about its own // axes, so the reflected shape is the same primitive posed (rx, -ry, -rz); a spline // reflects its points. A ghost's material is faded and it casts no shadow. + // 31-towers: the app's own BUILDING BLOCKS (Wedge / Arch / Corner / Stairs) — the same + // builders `/create Wedge` uses, fetched only when a def asks for one + const blockBuilders = d.objects.some((/** @type {any} */ o) => o.type === 'block') + ? (await import('/src/lib/customGeometries.js')).customGeometryBuilders + : null; /** @param {any} o @param {{mirror?: boolean, prefix?: string, opacity?: number, shadow?: boolean}} [opts] */ const build = (o, opts = {}) => { const mirror = !!opts.mirror; @@ -2473,6 +2343,13 @@ const DEFS = [ else if (o.type === 'icosahedron') geo = new T.IcosahedronGeometry(o.r, o.detail ?? 0); else if (o.type === 'dodecahedron') geo = new T.DodecahedronGeometry(o.r, o.detail ?? 0); else if (o.type === 'cone') geo = new T.ConeGeometry(o.r, o.h, 24); + // 31-towers: a building block, bottom-anchored like `/create Wedge` (origin on the floor) + else if (o.type === 'block') { + const make = blockBuilders?.[o.shape]; + if (!make || !['Wedge', 'Arch', 'Corner', 'Stairs'].includes(o.shape)) + throw new Error('object "' + o.name + '": unknown block "' + o.shape + '" (Wedge | Arch | Corner | Stairs)'); + geo = make(...(o.args ?? [])); + } else throw new Error('object "' + o.name + '": unknown type "' + o.type + '"'); // 30 author-kit: MeshPhysicalMaterial when a def asks for `physical` or uses any // field only it has; MeshToonMaterial for `toon`. Absent all of those it is the @@ -2932,6 +2809,10 @@ const DEFS = [ if (live) clone.add(live.clone(true)); else console.log(' WARN thumb.sceneGroups: no scene-root group named ' + name); } + // 31-towers: CARD-ONLY dressing — objects built for the picture and never saved + // (the scene was exported above). A Towers level deals its pieces at play time, so + // an honest card of the FILE is an empty arena; the card shows a round in progress. + for (const o of d.thumb?.dress ?? []) clone.add(build(o)); // camera markers are chrome, not scenery clone.traverse((/** @type {any} */ n) => { if (n.userData?.camera) n.visible = false; diff --git a/src/components/menu/Inspector.svelte b/src/components/menu/Inspector.svelte index 3e856961..505fc8e4 100644 --- a/src/components/menu/Inspector.svelte +++ b/src/components/menu/Inspector.svelte @@ -2614,6 +2614,26 @@ onchange={(/** @type {any} */ val) => setScenePhysics({ play: { interaction: val } })} /> + + setScenePhysics({ play: { reach: e.currentTarget.checked ? 1.3 : null } })} + > + Limit grab reach + + {#if $scenePlay.reach != null} + setScenePhysics({ play: { reach: v } })} + /> + {/if} {#if carrying} @@ -43,6 +44,9 @@ {reticle.distance.toFixed(1)} m {#if !hintSeen}· scroll to push / pull{/if} + {:else if reticle.mode === 'toofar'} + +
Too far — get closer
{:else if reticle.blocked}
held by {reticle.blocked}
{/if} @@ -82,6 +86,14 @@ .reticle.carrying { border-color: var(--icon-strong, #e5e7eb); } + /* 31-towers P1: grabbable, but out of reach — a smaller, dashed, warm ring */ + .reticle.toofar { + width: 12px; + height: 12px; + background: transparent; + border: 2px dashed var(--icon-warning, #f59e0b); + opacity: 0.85; + } .reticle-note { font-size: 11px; color: var(--icon-strong, #e5e7eb); diff --git a/src/lib/playInteract.js b/src/lib/playInteract.js index baf66577..f5133b89 100644 --- a/src/lib/playInteract.js +++ b/src/lib/playInteract.js @@ -21,6 +21,9 @@ import { nameOf } from './lockControl'; import { moduleInteractiveGroups, fireClickMiss, runClickHandlers } from './moduleSDK'; // 30 P3: where play mode aims — the crosshair under a lock, the cursor in a free-cursor game import { playAimNdc, playCursorFree } from './playCursor'; +// 31-towers P1: grab reach, measured from the player's body +import { withinReach, carryLimit } from './playReach'; +import { charControl } from './charController'; // 21-B B3: play mode becomes INTERACT mode — a crosshair grab at distance, // scroll to push and pull, and a release that throws with the velocity you @@ -61,9 +64,14 @@ const TAP_MS = 400; // matches the editor's own short-click window (raycastSelec const MOVE_HZ = 20; // carry broadcasts (the car module's cadence; the floor is // 8 Hz, because EXTERNAL_HOLD_MS is 250) -/** what the reticle renders from: aim + carry state, LOCAL and never on the wire */ +/** the eye above the feet when no Character Controller says otherwise (a standing person) */ +const BODY_EYE_HEIGHT = 1.7; + +/** what the reticle renders from: aim + carry state, LOCAL and never on the wire. + * 31-towers P1: 'toofar' = the crosshair is on something you could carry, beyond the scene's + * `play.reach` from your body. */ export const playInteractState = writable({ - /** @type {'off'|'idle'|'aiming'|'carrying'} */ + /** @type {'off'|'idle'|'aiming'|'toofar'|'carrying'} */ mode: 'off', distance: CARRY_DEFAULT, /** @type {string|null} */ uuid: null, @@ -109,6 +117,29 @@ function simRunning() { return !!get(simulating) || !!get(remoteSimulating); } +/** 31-towers P1: the scene's grab reach (metres from the body), or null for no limit */ +function playReach() { + return resolvePlaySettings(get(globalScene)).reach; +} + +/** the eye above the feet: the Character Controller's own, else a standing person */ +function eyeHeightNow() { + const h = Number(get(charControl)?.eyeHeight); + return Number.isFinite(h) && h > 0 ? h : BODY_EYE_HEIGHT; +} + +/** Is a hit point within reach of the player whose eye is `camera`? (always, with no reach) + * @param {any} point @param {any} camera */ +function inReach(point, camera) { + const reach = playReach(); + if (reach == null || !point) return true; + const eye = camera.getWorldPosition(new THREE.Vector3()); + return withinReach(point, eye, eye.y - eyeHeightNow(), reach); +} + +/** the refusals the reach made (debug hook only) */ +let reachRefusals = 0; + /** uuids that get a DYNAMIC body at sim start — scenery and static level * geometry can never be dragged, which is what makes 'grab' safe as a default */ function dynamicUuids() { @@ -336,6 +367,12 @@ function onPointerDown(event) { if (event.pointerType === 'touch') return; if (!simRunning()) return; // nothing to hold; the tap path still works if (!dynamicUuids().has(target.uuid)) return; + // 31-towers P1: beyond the scene's reach from your body — the reticle already says so + if (!inReach(hit.point, activeCamera)) { + reachRefusals++; + lastUp = 'too-far'; + return; + } const lock = get(lockedObjects).find((/** @type {any} */ entry) => entry[1] === target.uuid); if (lock) { playInteractState.update((s) => ({ ...s, blocked: nameOf(lock[0]) })); @@ -468,7 +505,10 @@ export function tickPlayInteract(delta, camera) { raycaster.setFromCamera(centre, camera); camDir.copy(raycaster.ray.direction); } - targetPos.copy(camPos).addScaledVector(camDir, carryDistance); + // 31-towers P1: a play carry is held WITHIN reach of the body (the editor's cursor + // carry has no body, so it keeps its distance) + const limit = grab.cursor ? carryDistance : carryLimit(camDir, eyeHeightNow(), playReach(), carryDistance); + targetPos.copy(camPos).addScaledVector(camDir, Math.min(carryDistance, limit)); // dt-based, so a throttled tab does not change the feel const k = Math.min(SPRING_K_MAX, Math.max(SPRING_K_MIN, SPRING_K / Math.sqrt(Math.max(grab.mass, 1)))); const alpha = 1 - Math.exp(-k * Math.max(delta, 1e-3)); @@ -515,7 +555,7 @@ export function tickPlayInteract(delta, camera) { const grabbable = mode === 'grab' && !!target && simRunning() && dynamicUuids().has(target.uuid); const state = get(playInteractState); - const next = grabbable ? 'aiming' : 'idle'; + const next = grabbable ? (inReach(hit.point, camera) ? 'aiming' : 'toofar') : 'idle'; if (state.mode !== next || state.uuid !== (target?.uuid ?? null)) playInteractState.set({ mode: next, @@ -571,6 +611,8 @@ export function playInteractDebug() { held: !!grab?.held, distance: carryDistance, lastUp, - samples: grab?.samples.length ?? 0 + samples: grab?.samples.length ?? 0, + reach: playReach(), + reachRefusals }; } diff --git a/src/lib/playReach.js b/src/lib/playReach.js new file mode 100644 index 00000000..7bc2754f --- /dev/null +++ b/src/lib/playReach.js @@ -0,0 +1,90 @@ +// 31-towers P1: GRAB REACH — how far from your body a player may take hold of something. +// +// A pure LEAF (imports nothing), so the rule is unit-tested with no scene, no headset and no +// pointer lock, and both input paths (desktop Play's crosshair in playInteract, a VR grip in +// vrControls) ask the same question. +// +// THE RULE: the distance from the point you are aiming at to your BODY — the vertical segment +// from your feet to your eye — must not exceed the scene's `play.reach`. Measuring from the eye +// alone was the obvious first reading and it is wrong twice: a crate lying at your feet is 1.7 m +// from your eye, so a 1.3 m reach would refuse the thing you are standing over, and a crate on a +// ledge right above your head would read closer than one at arm's length in front of you. The +// segment answers the way a body does: anything between your feet and your eye is as far away +// as it is horizontally, and above your eye the reach bends round your head. +// +// Absent (`null`) = no limit, which is every scene before this existed. + +/** metres: the smallest and largest reach a scene may ask for */ +export const REACH_MIN = 0.3; +export const REACH_MAX = 20; + +/** + * The scene field's one boundary: a finite number clamped into range, or null for "no limit". + * @param {any} value @returns {number | null} + */ +export function normalizeReach(value) { + if (value == null || value === '' || typeof value === 'boolean') return null; + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return null; + return Math.max(REACH_MIN, Math.min(REACH_MAX, n)); +} + +/** + * Distance from `point` to the body segment (feet..eye, straight up). + * @param {{x: number, y: number, z: number}} point what the player aims at (world) + * @param {{x: number, y: number, z: number}} eye the player's eye / head (world) + * @param {number} feetY the height of the feet (world); clamped to at most the eye + * @returns {number} + */ +export function bodyDistance(point, eye, feetY) { + const top = eye.y; + const bottom = Math.min(Number.isFinite(feetY) ? feetY : top, top); + const y = Math.max(bottom, Math.min(top, point.y)); + const dx = point.x - eye.x; + const dy = point.y - y; + const dz = point.z - eye.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +/** + * Is `point` within `reach` of the body? A null reach always answers true. + * @param {{x: number, y: number, z: number}} point + * @param {{x: number, y: number, z: number}} eye + * @param {number} feetY + * @param {number | null} reach + */ +export function withinReach(point, eye, feetY, reach) { + if (reach == null) return true; + return bodyDistance(point, eye, feetY) <= reach + 1e-6; +} + +/** + * The farthest a carried object may be held out along a ray from the eye while staying within + * reach: the carry distance along the aim that keeps the carried point on the reach boundary. + * Looking straight ahead that is the reach itself; looking DOWN it is longer (the body segment + * is under you), looking UP it is the reach again (above the eye the boundary is a sphere). + * Returns `fallback` when there is no reach. + * @param {{x: number, y: number, z: number}} dir the aim direction (unit) + * @param {number} eyeHeight the eye above the feet (>= 0) + * @param {number | null} reach + * @param {number} fallback + */ +export function carryLimit(dir, eyeHeight, reach, fallback) { + if (reach == null) return fallback; + // along the ray t, the point is (t*dx, t*dy, t*dz) relative to the eye. While it is within + // the segment's height band (-eyeHeight <= t*dy <= 0) the distance is the horizontal one; + // otherwise the offset from the nearer end of the segment. + const h = Math.hypot(dir.x, dir.z); + if (dir.y >= 0) return reach; // above the eye: a sphere of `reach` round it + // descending: horizontal distance grows as t*h while t*|dy| <= eyeHeight + const tBand = eyeHeight / -dir.y; + const tHorizontal = h > 1e-6 ? reach / h : Infinity; + if (tHorizontal <= tBand) return tHorizontal; + // below the feet: distance from the foot point (0, -eyeHeight, 0) must equal reach + // |t*d - f|^2 = reach^2 with f = (0, -eyeHeight, 0): t^2 - 2 t (d.f) + |f|^2 - reach^2 = 0 + const df = -dir.y * eyeHeight; + const c = eyeHeight * eyeHeight - reach * reach; + const disc = df * df - c; + if (disc < 0) return tBand; + return df + Math.sqrt(disc); +} diff --git a/src/lib/playSettings.js b/src/lib/playSettings.js index 973aed71..a821fdee 100644 --- a/src/lib/playSettings.js +++ b/src/lib/playSettings.js @@ -3,6 +3,7 @@ import { scenePlay } from './scenePhysics'; import { showToast } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; import { moduleWorldChildren } from './moduleWorld'; +import { normalizeReach } from './playReach'; /** * 30b P4: a spawn point set at RUNTIME by a module (`api.setSpawn(position, yaw)`) — a @@ -74,8 +75,11 @@ export function playPublishers(scene) { * 30b P3/P4: `locomotion` ({teleport, fly}, both false unless the scene or a publisher * allows them — field by field, like `grounded`) and `spawn` (the runtime api.setSpawn, * else a publisher's `userData.play.spawn`, else the scene's `play.spawn`, else null). + * 31-towers P1: `reach` — how far from the player's BODY a grab may start (metres, playReach.js), + * null = no limit; a publisher's `userData.play.reach` overrides the scene's. * @returns {{interaction: 'grab'|'click'|'off', grounded: boolean, eyeHeight: number, cursor: 'free'|'locked', - * locomotion: {teleport: boolean, fly: boolean}, spawn: {position: [number, number, number], yaw: number} | null}} + * locomotion: {teleport: boolean, fly: boolean}, spawn: {position: [number, number, number], yaw: number} | null, + * reach: number | null}} */ export function resolvePlaySettings(scene) { const base = get(scenePlay); @@ -86,7 +90,8 @@ export function resolvePlaySettings(scene) { eyeHeight: DEFAULT_EYE_HEIGHT, cursor: base.cursor === 'free' ? 'free' : 'locked', locomotion: { teleport: false, fly: false }, - spawn: normalizeSpawn(base.spawn) + spawn: normalizeSpawn(base.spawn), + reach: normalizeReach(base.reach) }; const baseLoco = normalizeLocomotion(base.locomotion); if (baseLoco) Object.assign(out.locomotion, baseLoco); @@ -111,6 +116,8 @@ export function resolvePlaySettings(scene) { if (loco) Object.assign(out.locomotion, loco); const spawn = normalizeSpawn(play.spawn); if (spawn) out.spawn = spawn; + const reach = normalizeReach(play.reach); + if (reach != null) out.reach = reach; } const runtime = get(runtimeSpawn); if (runtime) out.spawn = { position: runtime.position, yaw: runtime.yaw }; diff --git a/src/lib/scenePhysics.js b/src/lib/scenePhysics.js index 43491845..c8494efa 100644 --- a/src/lib/scenePhysics.js +++ b/src/lib/scenePhysics.js @@ -2,6 +2,7 @@ import { writable, derived, get } from 'svelte/store'; import { sessionNow } from './sessionClock'; // 25-E: stamps another peer compares import { peers } from '../stores/appStore'; import { normalizeLocomotion, normalizeSpawn } from './locomotionPolicy'; // 30b P3/P4 +import { normalizeReach } from './playReach'; // 31-towers P1 // CL-A A6 / 21-B B1: scene-wide physics settings. ONE shared object for the // whole session, replicated as its OWN latest-wins singleton message (the @@ -159,9 +160,12 @@ export function normalizeScenePhysics(raw) { // 30b P3/P4: present only when authored (locomotionPolicy's normalizers), so a // scene that never used them stays byte-identical ...optional('locomotion', normalizeLocomotion(playRaw.locomotion)), - ...optional('spawn', normalizeSpawn(playRaw.spawn)) + ...optional('spawn', normalizeSpawn(playRaw.spawn)), + // 31-towers P1: grab REACH in metres from the player's body — present only when + // authored (absent = no limit, every scene before it) + ...optional('reach', normalizeReach(playRaw.reach)) }, - ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn'] + ['interaction', 'grounded', 'simOnPlay', 'cursor', 'locomotion', 'spawn', 'reach'] ), // A1: the 20 ceiling is throwVelocity's MAX_LINVEL, restated rather than imported — // this module is store-only and the response clamps through clampThrow anyway diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index 0c469a3d..a0db7b5e 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -45,7 +45,8 @@ import { isScenery, pickGripTarget, gripMovesWorld } from './vrGrip'; import { resolvePlaySettings, playPublishers } from './playSettings'; import { hudDocs, isGameHud } from './hudDocs'; import { locomotionPolicy, vrSpawnOffsets, yawForward } from './locomotionPolicy'; -import { resolveWalk } from './charController'; +import { resolveWalk, charControl, setJumpRequested } from './charController'; +import { withinReach } from './playReach'; // 31-towers P1 import { activeRing, findMenuEntry, ringEntries, sectorFromStick, pushRing, popRing, resetRings, hubEntry } from './vrRadialMenu'; import { paletteColorAt, barValueAt } from './vrPalette'; // 30b (vr-play) C4: the ONE game-feel predicate (a leaf) + the pattern shapes (pure) @@ -1024,8 +1025,11 @@ function offsetSpace(offset, orientation) { /** * 30b P3: ONE walker step as data — the wanted displacement from the stick, resolved against * the world. Exported so a suite drives it with a real simulation and no headset. + * 31-towers P1: `jumpHeight` (metres) lets a jump edge (right A, `setJumpRequested`) leave the + * ground — the Character Controller node's own jump, so desktop and VR share one authoring place. * @param {{head: {x: number, y: number, z: number}, headHeight: number, yaw: number, - * stick: {x: number, y: number}, dt: number, fly?: boolean, aim?: {x: number, y: number, z: number}}} input + * stick: {x: number, y: number}, dt: number, fly?: boolean, aim?: {x: number, y: number, z: number}, + * jumpHeight?: number}} input * @returns {{dx: number, dy: number, dz: number, feet: number, grounded: boolean, source: string}} */ export function vrWalkStep(input) { @@ -1047,7 +1051,8 @@ export function vrWalkStep(input) { const height = Math.min(2.1, Math.max(1, Math.round(input.headHeight * 10) / 10)); const feet = input.head.y - input.headHeight; const r = resolveWalk({ x: input.head.x, y: feet, z: input.head.z }, height, dt, desired, { - gravity: !input.fly + gravity: !input.fly, + jumpHeight: input.fly ? 0 : Number(input.jumpHeight) || 0 }); return { dx: r.dx, dy: r.feet - feet, dz: r.dz, feet: r.feet, grounded: r.grounded, source: r.source }; } @@ -1082,7 +1087,8 @@ export function tickVRInteractLocomotion(dt, session) { stick: { x: axes[2] ?? 0, y: axes[3] ?? 0 }, dt, fly: policy.fly, - aim + aim, + jumpHeight: vrJumpHeight() }); // fell out of the world: back to the spawn (or the origin) if (step.feet < -50) { @@ -1095,6 +1101,21 @@ export function tickVRInteractLocomotion(dt, session) { return true; } +/** + * 31-towers P1: the jump the VR walker has right now — the Character Controller node's + * `jumpHeight` while it declares WALK mode and Interact walks (not flies); 0 = no jump, and the + * right A button stays push-to-talk. + * @returns {number} + */ +export function vrJumpHeight() { + const control = get(charControl); + if (!control || control.mode !== 'walk') return 0; + const policy = vrLocomotionNow(); + if (!policy.walk || policy.fly) return 0; + const h = Number(control.jumpHeight); + return Number.isFinite(h) && h > 0 ? h : 0; +} + /** * 30b P4: put the player on the game's spawn — the runtime api.setSpawn, else the scene's * `play.spawn` (resolvePlaySettings). In VR the FEET land on it facing its yaw; on the @@ -2472,6 +2493,8 @@ function onSqueezeStart(index) { const mode = get(editorMode) === 'interact' ? 'interact' : 'edit'; let object = gripTargetOf(controllerRay(index), controller.getWorldPosition(new THREE.Vector3()), mode); if (!object) { + // 31-towers P1: a piece beyond the scene's reach says so with a short buzz, no more + if (lastGripRefusal) hapticPattern('fail', renderer.xr.getController(index)?.userData?.handedness ?? undefined); // 30b P2: Interact's grips never move the world (contract C1) if (!gripMovesWorld(mode)) return; emptyAirSqueeze[index] = true; @@ -2557,38 +2580,72 @@ function onSqueezeStart(index) { * @param {any} handPos the controller's world position @param {'edit'|'interact'} mode */ export function gripTargetOf(ray, handPos, mode) { + lastGripRefusal = null; const group = get(objectsGroup); if (!group) return null; /** @type {any} */ const camera = get(globalCamera); const head = camera ? camera.getWorldPosition(new THREE.Vector3()) : null; const locked = get(lockedObjects); - const interaction = mode === 'interact' ? resolvePlaySettings(get(globalScene)).interaction : 'grab'; - /** @param {any} object */ - const describe = (object) => { + const settings = mode === 'interact' ? resolvePlaySettings(get(globalScene)) : null; + const interaction = settings ? settings.interaction : 'grab'; + // 31-towers P1: a player's grip reaches `play.reach` from the BODY (head down to the feet) + const reach = settings?.reach ?? null; + const feetY = head ? head.y - (viewerNow()?.headHeight ?? STANDING_HEAD) : 0; + /** @param {any} object @param {any} point where the grip would take it */ + const describe = (object, point) => { const box = new THREE.Box3().setFromObject(object); + const holdable = + interaction === 'grab' && + object.userData?.physics?.mode === 'dynamic' && + !locked.find((/** @type {any} */ lock) => lock[1] === object.uuid); + const near = !holdable || reach == null || !head || withinReach(point, head, feetY, reach); + if (holdable && !near) lastGripRefusal = { uuid: object.uuid, reach }; return { scenery: isScenery(box.isEmpty() ? null : box, head), - grabbable: - interaction === 'grab' && - object.userData?.physics?.mode === 'dynamic' && - !locked.find((/** @type {any} */ lock) => lock[1] === object.uuid) + grabbable: holdable && near }; }; /** @type {any[]} */ const order = []; + /** @type {any[]} */ + const points = []; for (const hit of ray.intersectObjects(group.children, true)) { const top = topLevelObjectOf(hit.object); - if (top && !order.includes(top)) order.push(top); + if (top && !order.includes(top)) { + order.push(top); + points.push(hit.point); + } + } + const picked = pickGripTarget( + order.map((object, i) => describe(object, points[i])), + mode + ); + if (picked >= 0) { + lastGripRefusal = null; + return order[picked]; } - const picked = pickGripTarget(order.map(describe), mode); - if (picked >= 0) return order[picked]; // a hand INSIDE an object needs no pointer (100.3) — the same rule decides const inside = containedTopLevel(handPos, group); - if (inside && pickGripTarget([describe(inside)], mode) === 0) return inside; + if (inside && pickGripTarget([describe(inside, handPos)], mode) === 0) { + lastGripRefusal = null; + return inside; + } return null; } +/** 31-towers P1: is push-to-talk open because A is held? (A jumps instead while the game's + * controller can jump — a switch mid-hold must not strand the mic open) */ +let pttByA = false; + +/** 31-towers P1: the grip the reach refused last ({uuid, reach}), for the buzz and the suite + * @type {{uuid: string, reach: number} | null} */ +let lastGripRefusal = null; +/** @returns {{uuid: string, reach: number} | null} */ +export function lastGripRefusalDebug() { + return lastGripRefusal; +} + /** @param {number} index */ function onSqueezeEnd(index) { // face grab (122): release commits the reshape as one meshgeo + undo entry @@ -3491,9 +3548,20 @@ export function updateVRControls() { } - // right A held = push-to-talk + // right A held = push-to-talk — or, 31-towers P1, JUMP while the game's Character + // Controller can jump (Interact walking). The edge is the charController's own, so a held + // A is one jump and landing with it down is none (no bunny-hopping). const aPressed = !!buttons[4]?.pressed; - if (source.handedness === 'right' && aPressed !== !!prev.a) setPttHeld(aPressed); + if (source.handedness === 'right' && aPressed !== !!prev.a) { + if (vrJumpHeight() > 0) { + setJumpRequested(aPressed); + if (pttByA) setPttHeld(false); + pttByA = false; + } else { + setPttHeld(aPressed); + pttByA = aPressed; + } + } prev.a = aPressed; // K-C: publish this hand's stick + trigger/squeeze into the SDK input diff --git a/src/modules/index.js b/src/modules/index.js index 92a5d218..83b95c01 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -6,5 +6,7 @@ import hello from './hello/module.js'; import button from './button/module.js'; import pong from './pong/module.js'; import vrsleeve from './vrsleeve/module.js'; +// 31-towers: the Towers game's levels, pieces and rules (dormant outside a Towers scene) +import towers from './towers/module.js'; -export const coreModules = [hello, button, pong, vrsleeve]; +export const coreModules = [hello, button, pong, vrsleeve, towers]; diff --git a/src/modules/towers/levels.js b/src/modules/towers/levels.js new file mode 100644 index 00000000..7addde8e --- /dev/null +++ b/src/modules/towers/levels.js @@ -0,0 +1,479 @@ +// 31-towers P2: THE TOWERS LEVELS — data and arithmetic, nothing else. +// +// A pure LEAF (imports nothing): the twelve levels, the star rule, the unlock chain, the supply +// layout on the racks, the tower measure, the verdict stepper, the gust schedule and the +// outline test are all functions of their arguments, so tests/unit/towersLevels.test.js covers +// them with no scene and no browser. The runtime (module.js) measures the SCENE (zones, racks +// and piece templates are found by NAME, so the arena lives in one place — the template def) +// and hands the numbers in. +// +// A LEVEL: {id, name, zone, supply: [{shape, count, rack?}], goal: {type, height?}, rule?, +// wind?, par: {pieces, time}, limit (seconds), lost (pieces you may lose), intro}. +// zone 'pad' | 'pedestal' | 'wobble' — the scene objects `Build pad` / `Pedestal` / +// `Wobble plate` (their tops are measured) +// rack 'racks' (default: the two low racks either side of the spawn) | 'ledge' (the high +// ledge — above your reach from the floor) | 'perch' (the star perch) +// goal {type: 'height', height} — the tower's top above the floor · {type: 'outline'} — +// fill every cell of the ghost wall · {type: 'deliver', height} — the STAR on top + +/** the piece shapes: the scene's template object for each, and how it may be laid on a rack + * @type {Record} */ +export const SHAPES = { + cube: { template: 'Piece cube', label: 'cube', stack: 2 }, + plank: { template: 'Piece plank', label: 'plank', stack: 2 }, + beam: { template: 'Piece beam', label: 'beam', stack: 2 }, + wedge: { template: 'Piece wedge', label: 'wedge', stack: 1 }, + barrel: { template: 'Piece barrel', label: 'barrel', stack: 1 }, + arch: { template: 'Piece arch', label: 'arch', stack: 1 }, + lblock: { template: 'Piece L', label: 'L-block', stack: 1 }, + ball: { template: 'Piece ball', label: 'ball', stack: 1 }, + base: { template: 'Piece base', label: 'heavy base', stack: 1 }, + star: { template: 'Piece star', label: 'star', stack: 1 } +}; + +/** the zones a level builds on: the scene object's NAME and how wide a tower may stand + * @type {Record} */ +export const ZONES = { + pad: { object: 'Build pad', footprint: 1.9, yard: 3.4 }, + pedestal: { object: 'Pedestal', footprint: 0.75, yard: 2.6 }, + wobble: { object: 'Wobble plate', footprint: 1.45, yard: 2.9 } +}; + +/** the supply racks, by the scene objects that carry them @type {Record} */ +export const RACKS = { + racks: ['Rack west', 'Rack east'], + ledge: ['High ledge'], + perch: ['Star perch'] +}; + +/** seconds a finished tower must stand still before it counts */ +export const HOLD_SECONDS = 3; +/** a piece at rest moves slower than this (m/s) */ +export const REST_SPEED = 0.3; +/** a tower that had reached this share of the goal... */ +export const FALL_FROM = 0.6; +/** ...and drops under this share within FALL_WINDOW seconds has FALLEN */ +export const FALL_TO = 0.3; +export const FALL_WINDOW = 2.5; +/** a piece resting outside the yard for this long is lost */ +export const LOST_AFTER = 1.2; + +/** @type {any[]} */ +export const LEVELS = [ + { + id: 1, name: 'Stack', zone: 'pad', supply: [{ shape: 'cube', count: 6 }], + goal: { type: 'height', height: 1.8 }, par: { pieces: 3, time: 45 }, limit: 180, lost: 2, + intro: 'Carry cubes from the racks to the glowing pad and stack them to the ring.' + }, + { + id: 2, name: 'Planks', zone: 'pad', supply: [{ shape: 'plank', count: 4 }, { shape: 'beam', count: 2 }, { shape: 'cube', count: 2 }], + goal: { type: 'height', height: 2.2 }, par: { pieces: 5, time: 70 }, limit: 180, lost: 2, + intro: 'Long pieces make wide floors. Cross them to build a stable tower.' + }, + { + id: 3, name: 'Climb', zone: 'pad', supply: [{ shape: 'cube', count: 5 }, { shape: 'cube', count: 3, rack: 'ledge' }], + goal: { type: 'height', height: 3.6 }, par: { pieces: 6, time: 120 }, limit: 240, lost: 2, + rule: 'Above your reach: build steps and climb them. Jump (Space / A) to grab from the ledge.', + intro: 'The ring is higher than you can reach. Build a step, climb it — and jump for the ledge.' + }, + { + id: 4, name: 'Wedges', zone: 'pad', supply: [{ shape: 'wedge', count: 8 }, { shape: 'cube', count: 2 }], + goal: { type: 'height', height: 2.2 }, par: { pieces: 7, time: 100 }, limit: 210, lost: 2, + rule: 'Two wedges face to face make a flat top.', + intro: 'Triangles! Pair two wedges into a block before you build on them.' + }, + { + id: 5, name: 'Barrels', zone: 'pad', supply: [{ shape: 'barrel', count: 6 }, { shape: 'plank', count: 2 }], + goal: { type: 'height', height: 2.4 }, par: { pieces: 5, time: 90 }, limit: 210, lost: 2, + rule: 'Barrels roll — stand them up.', + intro: 'Barrels stand on their ends and roll on their sides. Keep them upright.' + }, + { + id: 6, name: 'Arches', zone: 'pad', supply: [{ shape: 'arch', count: 3 }, { shape: 'lblock', count: 3 }, { shape: 'plank', count: 2 }], + goal: { type: 'height', height: 2.8 }, par: { pieces: 5, time: 110 }, limit: 240, lost: 2, + intro: 'Arches and L-blocks: odd shapes, big gains. Find how they lock together.' + }, + { + id: 7, name: 'Narrow base', zone: 'pedestal', supply: [{ shape: 'cube', count: 3 }, { shape: 'plank', count: 3 }, { shape: 'beam', count: 1 }], + goal: { type: 'height', height: 2.6 }, par: { pieces: 4, time: 90 }, limit: 210, lost: 2, + rule: 'The pedestal is small — balance every overhang.', + intro: 'Build on the little pedestal. Every piece hangs over the edge.' + }, + { + id: 8, name: 'Gusts', zone: 'pad', supply: [{ shape: 'cube', count: 4 }, { shape: 'plank', count: 3 }, { shape: 'wedge', count: 2 }], + goal: { type: 'height', height: 3.0 }, wind: { first: 8, every: 9, strength: 2 }, + par: { pieces: 7, time: 120 }, limit: 240, lost: 3, + rule: 'A gust every 9 s — hold the top piece through it, and build wide.', + intro: 'The wind is up. A gust every few seconds pushes the top of your tower.' + }, + { + id: 9, name: 'Balls', zone: 'pad', supply: [{ shape: 'ball', count: 3 }, { shape: 'wedge', count: 4 }, { shape: 'plank', count: 2 }, { shape: 'cube', count: 2 }], + goal: { type: 'height', height: 2.4 }, par: { pieces: 6, time: 140 }, limit: 270, lost: 3, + rule: 'Balls roll off anything flat — cradle them.', + intro: 'Hard one. Balls will not sit still: make a cradle for each one.' + }, + { + id: 10, name: 'Wobble', zone: 'wobble', supply: [{ shape: 'base', count: 1 }, { shape: 'cube', count: 4 }, { shape: 'plank', count: 2 }], + goal: { type: 'height', height: 2.4 }, par: { pieces: 5, time: 100 }, limit: 240, lost: 3, + rule: 'The plate rocks — start with the heavy base.', + intro: 'The wobble plate never stops rocking. A heavy base steadies it.' + }, + { + id: 11, name: 'Outline', zone: 'pad', supply: [{ shape: 'cube', count: 6 }, { shape: 'plank', count: 2 }], + goal: { type: 'outline' }, par: { pieces: 6, time: 80 }, limit: 180, lost: 2, + rule: 'Fill every cell of the ghost wall.', + intro: 'No ring this time: fill the glowing ghost wall on the pad, every cell.' + }, + { + id: 12, name: 'Summit', zone: 'pad', + supply: [ + { shape: 'base', count: 1 }, { shape: 'cube', count: 3 }, { shape: 'plank', count: 2 }, { shape: 'arch', count: 1 }, + { shape: 'lblock', count: 1 }, { shape: 'wedge', count: 2 }, { shape: 'barrel', count: 1 }, { shape: 'star', count: 1, rack: 'perch' } + ], + goal: { type: 'deliver', height: 3.6 }, wind: { first: 12, every: 14, strength: 1.3 }, + par: { pieces: 9, time: 180 }, limit: 360, lost: 3, + rule: 'Fetch the star from the perch and set it on top. Light gusts.', + intro: 'The summit: build to 3.6 m and crown it with the star from the perch.' + } +]; + +/** @param {any} id @returns {any | null} */ +export function levelById(id) { + const n = Number(id); + return LEVELS.find((l) => l.id === n) ?? null; +} + +/** @param {any} id @returns {number | null} the next level's id, or null after the last */ +export function nextLevelId(id) { + const n = Number(id); + return LEVELS.some((l) => l.id === n + 1) ? n + 1 : null; +} + +/** how many pieces a level deals @param {any} level */ +export function supplyCount(level) { + return (level?.supply ?? []).reduce((/** @type {number} */ a, /** @type {any} */ s) => a + (s.count || 0), 0); +} + +/** The goal height of a level in metres (an outline's is its top row). @param {any} level */ +export function goalHeight(level) { + if (!level) return 0; + if (level.goal?.type === 'outline') return 1.4; + return Number(level.goal?.height) || 0; +} + +// ---- stars, progress, unlocks ------------------------------------------------------------------ + +/** + * ★ for winning, +★ for a tower of no more than par pieces, +★ inside par time. + * @param {any} level @param {{won: boolean, time: number, pieces: number}} result + * @returns {number} 0..3 + */ +export function starsFor(level, result) { + if (!level || !result?.won) return 0; + let stars = 1; + if (Number(result.pieces) <= level.par.pieces) stars++; + if (Number(result.time) <= level.par.time) stars++; + return stars; +} + +/** '★★☆' @param {number} n @param {number=} of */ +export function starsText(n, of = 3) { + const k = Math.max(0, Math.min(of, Math.round(Number(n) || 0))); + return '★'.repeat(k) + '☆'.repeat(of - k); +} + +/** m:ss @param {number} seconds */ +export function formatTime(seconds) { + const s = Math.max(0, Math.ceil(Number(seconds) || 0)); + return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); +} + +/** + * The saved progress at the storage boundary: only known level ids, finite numbers, stars 0..3. + * @param {any} raw @returns {{levels: Record}} + */ +export function normalizeProgress(raw) { + /** @type {Record} */ + const levels = {}; + const src = raw && typeof raw === 'object' && raw.levels && typeof raw.levels === 'object' ? raw.levels : {}; + for (const level of LEVELS) { + const row = src[level.id] ?? src[String(level.id)]; + if (!row || typeof row !== 'object') continue; + const stars = Math.max(0, Math.min(3, Math.round(Number(row.stars) || 0))); + if (!stars) continue; + const time = Number(row.time); + const pieces = Number(row.pieces); + levels[String(level.id)] = { + stars, + time: Number.isFinite(time) && time > 0 ? time : 0, + pieces: Number.isFinite(pieces) && pieces > 0 ? Math.round(pieces) : 0 + }; + } + return { levels }; +} + +/** + * Fold one finished level into the progress: the BEST of each number (most stars, fastest time, + * fewest pieces), never a worse run over a better one. A loss changes nothing. + * @param {any} progress @param {number} levelId @param {{stars: number, time: number, pieces: number}} result + */ +export function mergeResult(progress, levelId, result) { + const out = normalizeProgress(progress); + if (!(result?.stars > 0)) return out; + const key = String(levelId); + const was = out.levels[key]; + out.levels[key] = was + ? { + stars: Math.max(was.stars, result.stars), + time: was.time > 0 ? Math.min(was.time, result.time) : result.time, + pieces: was.pieces > 0 ? Math.min(was.pieces, result.pieces) : result.pieces + } + : { stars: result.stars, time: result.time, pieces: result.pieces }; + return normalizeProgress(out); +} + +/** Level 1 is always open; every other one opens with a star on the level before it. + * @param {any} progress @param {number} levelId */ +export function isUnlocked(progress, levelId) { + const n = Number(levelId); + if (!levelById(n)) return false; + if (n === 1) return true; + return (normalizeProgress(progress).levels[String(n - 1)]?.stars ?? 0) > 0; +} + +/** total stars earned @param {any} progress */ +export function totalStars(progress) { + return Object.values(normalizeProgress(progress).levels).reduce((a, r) => a + r.stars, 0); +} + +// ---- the supply layout ------------------------------------------------------------------------- + +/** + * Where a level's pieces are dealt: slots along each rack's LONG axis, a piece turned so its + * longest side lies across the rack when it fits, flat pieces stacked two high. Racks fill in + * order; a rack that is full passes the rest to the next. Pure: the rack boxes and the piece sizes + * come from the scene. + * @param {any} level + * @param {Record} rackBoxes world AABBs by rack object name + * @param {Record} sizes world size [x, y, z] of each shape's template, by shape + * @returns {{shape: string, pos: number[], yaw: number}[]} + */ +export function layoutSupply(level, rackBoxes, sizes) { + /** @type {{shape: string, pos: number[], yaw: number}[]} */ + const out = []; + const GAP = 0.12; + /** per rack name: how far along its long axis we have filled */ + /** @type {Record} */ + const used = {}; + for (const entry of level?.supply ?? []) { + const names = RACKS[entry.rack ?? 'racks'] ?? RACKS.racks; + const size = sizes[entry.shape]; + if (!size) continue; + const stackable = SHAPES[entry.shape]?.stack ?? 1; + let left = entry.count; + for (const name of names) { + const box = rackBoxes[name]; + if (!box || left <= 0) continue; + const dx = box.max[0] - box.min[0]; + const dz = box.max[2] - box.min[2]; + const alongZ = dz >= dx; // the rack's long axis + const across = alongZ ? dx : dz; + const length = alongZ ? dz : dx; + // turn the piece so its longest horizontal side lies ACROSS the rack when it fits + const long = Math.max(size[0], size[2]); + const short = Math.min(size[0], size[2]); + const turned = long <= across - 0.05; + const along = turned ? short : long; + // the yaw that puts the piece's long side across: size[0] is x at yaw 0 + const xIsLong = size[0] >= size[2]; + const acrossIsX = alongZ; + const yaw = turned === (xIsLong === acrossIsX) ? 0 : Math.PI / 2; + while (left > 0) { + const start = used[name] ?? 0; + if (start + along > length + 1e-6) break; + const mid = start + along / 2; + const layers = Math.min(stackable, left); + for (let k = 0; k < layers; k++) { + const x = alongZ ? (box.min[0] + box.max[0]) / 2 : box.min[0] + mid; + const z = alongZ ? box.min[2] + mid : (box.min[2] + box.max[2]) / 2; + const y = box.max[1] + size[1] * (k + 0.5) + 0.02 + k * 0.02; + out.push({ shape: entry.shape, pos: [round(x), round(y), round(z)], yaw }); + } + left -= layers; + used[name] = start + along + GAP; + } + } + } + return out; +} + +/** @param {number} n */ +function round(n) { + return Math.round(n * 1000) / 1000; +} + +// ---- measuring the tower ----------------------------------------------------------------------- + +/** + * The pieces that make the TOWER: resting, not held, their centre over the zone's footprint. + * @param {{uuid: string, pos: number[], top: number, speed: number, held: boolean}[]} pieces + * @param {{center: number[], footprint: number}} zone + */ +export function towerPieces(pieces, zone) { + return pieces.filter( + (p) => + !p.held && + p.speed < REST_SPEED && + Math.hypot(p.pos[0] - zone.center[0], p.pos[2] - zone.center[2]) <= zone.footprint + ); +} + +/** the tower's top (metres above the floor): the highest top of its pieces, else the zone's + * own surface @param {any[]} pieces @param {{center: number[], footprint: number, top: number}} zone */ +export function towerTop(pieces, zone) { + let top = zone.top ?? 0; + for (const p of towerPieces(pieces, zone)) if (p.top > top) top = p.top; + return top; +} + +/** Is this resting piece LOST — on the floor, outside the yard, off every rack? + * @param {{pos: number[], bottom: number, speed: number, held: boolean}} piece + * @param {{center: number[], yard: number}} zone + * @param {{min: number[], max: number[]}[]} rackBoxes */ +export function isOutside(piece, zone, rackBoxes) { + if (piece.held || piece.speed >= REST_SPEED) return false; + if (piece.bottom > 0.9) return false; // up on something — a rack, the ledge, the tower + if (Math.hypot(piece.pos[0] - zone.center[0], piece.pos[2] - zone.center[2]) <= zone.yard) return false; + for (const b of rackBoxes) + if (piece.pos[0] >= b.min[0] && piece.pos[0] <= b.max[0] && piece.pos[2] >= b.min[2] && piece.pos[2] <= b.max[2]) return false; + return true; +} + +// ---- the outline goal -------------------------------------------------------------------------- + +/** the ghost wall's cells: 3 wide x 2 high, 0.6 m, standing on the zone's surface, facing +Z + * @param {{center: number[], top: number}} zone @returns {number[][]} cell centres */ +export function outlineCells(zone) { + const cells = []; + for (let row = 0; row < 2; row++) + for (let col = -1; col <= 1; col++) + cells.push([round(zone.center[0] + col * 0.6), round((zone.top ?? 0) + 0.3 + row * 0.6), round(zone.center[2])]); + return cells; +} + +/** how many cells a resting piece's box holds @param {number[][]} cells + * @param {{min: number[], max: number[], held?: boolean, speed?: number}[]} boxes */ +export function cellsFilled(cells, boxes) { + let n = 0; + for (const c of cells) { + const hit = boxes.some( + (b) => + !b.held && + (b.speed ?? 0) < REST_SPEED && + c[0] >= b.min[0] - 0.05 && c[0] <= b.max[0] + 0.05 && + c[1] >= b.min[1] - 0.05 && c[1] <= b.max[1] + 0.05 && + c[2] >= b.min[2] - 0.05 && c[2] <= b.max[2] + 0.05 + ); + if (hit) n++; + } + return n; +} + +// ---- the wind ---------------------------------------------------------------------------------- + +/** + * The gust schedule: gust n comes `first + n * every` seconds into the round, from a direction + * that turns by the golden angle each time (so two gusts rarely push the same way). Every peer + * computes the same schedule from the replicated round clock — only the stepping peer PUSHES. + * @param {any} level @param {number} elapsed seconds into the round + * @returns {{index: number, at: number, dir: number[], strength: number} | null} the most + * recent gust at or before `elapsed`, or null before the first / with no wind + */ +export function gustAt(level, elapsed) { + const w = level?.wind; + if (!w || !(elapsed >= w.first)) return null; + const index = Math.floor((elapsed - w.first) / w.every); + return gust(level, index); +} + +/** gust `index` of a level @param {any} level @param {number} index */ +export function gust(level, index) { + const w = level.wind; + const angle = (index * 2.39996323 + level.id * 0.7) % (Math.PI * 2); + return { + index, + at: w.first + index * w.every, + dir: [round(Math.cos(angle)), 0, round(Math.sin(angle))], + strength: w.strength + }; +} + +/** a compass word for a gust direction (where it blows FROM) @param {number[]} dir */ +export function windFrom(dir) { + // the wind blows along dir, so it comes FROM -dir. Scene: -Z is north, +X east. + const x = -dir[0]; + const z = -dir[2]; + const deg = ((Math.atan2(x, -z) * 180) / Math.PI + 360) % 360; + return ['north', 'north-east', 'east', 'south-east', 'south', 'south-west', 'west', 'north-west'][Math.round(deg / 45) % 8]; +} + +// ---- the verdict ------------------------------------------------------------------------------- + +/** + * ONE STEP of the round's judge — pure, so the rules are tested frame by frame. The runtime keeps + * the returned `memo` and hands it back next time. + * @param {any} level + * @param {{elapsed: number, top: number, filled?: number, cells?: number, star?: {top: number, inTower: boolean} | null, + * anyHeld: boolean, lost: number, starLost?: boolean}} now + * @param {{holdSince: number | null, peak: number, peakAt: number} | null} memo + * @returns {{verdict: null | 'won' | 'time' | 'fell' | 'lost' | 'star', hold: number, memo: any}} + * `hold` = whole seconds left of the hold countdown (0 = not holding) + */ +export function judge(level, now, memo) { + const m = { holdSince: memo?.holdSince ?? null, peak: memo?.peak ?? 0, peakAt: memo?.peakAt ?? 0 }; + const goal = goalHeight(level); + if (now.top > m.peak) { + m.peak = now.top; + m.peakAt = now.elapsed; + } + if (now.starLost) return { verdict: 'star', hold: 0, memo: m }; + if (now.lost > level.lost) return { verdict: 'lost', hold: 0, memo: m }; + // a COLLAPSE: the tower had got well up and came down fast (taking pieces off one at a time + // is slower than the window, so rebuilding is not a fall) + if (goal > 0 && m.peak >= goal * FALL_FROM && now.top < goal * FALL_TO && now.elapsed - m.peakAt <= FALL_WINDOW && !now.anyHeld) + return { verdict: 'fell', hold: 0, memo: m }; + let met = false; + if (level.goal?.type === 'outline') met = (now.cells ?? 0) > 0 && (now.filled ?? 0) >= (now.cells ?? 0); + else if (level.goal?.type === 'deliver') met = !!now.star?.inTower && now.star.top >= goal; + else met = now.top >= goal; + if (met && !now.anyHeld) { + if (m.holdSince == null) m.holdSince = now.elapsed; + const held = now.elapsed - m.holdSince; + if (held >= HOLD_SECONDS) return { verdict: 'won', hold: 0, memo: m }; + // a new peak keeps the collapse window honest, but holding still is not a fall + return { verdict: null, hold: Math.max(1, Math.ceil(HOLD_SECONDS - held)), memo: m }; + } + m.holdSince = null; + if (now.elapsed >= level.limit) return { verdict: 'time', hold: 0, memo: m }; + return { verdict: null, hold: 0, memo: m }; +} + +/** the words for a verdict @param {string} verdict */ +export function verdictTitle(verdict) { + return ( + { + won: 'Level complete!', + time: 'Out of time', + fell: 'The tower fell', + lost: 'Out of pieces', + star: 'The star was lost' + }[verdict] ?? '' + ); +} + +/** the numeric status the game variable carries (0 = no round) */ +export const STATUS = { none: 0, playing: 1, won: 2, time: 3, fell: 4, lost: 5, star: 6 }; +/** @param {number} code @returns {string} */ +export function statusName(code) { + return Object.keys(STATUS).find((k) => STATUS[/** @type {keyof typeof STATUS} */ (k)] === code) ?? 'none'; +} diff --git a/src/modules/towers/module.js b/src/modules/towers/module.js new file mode 100644 index 00000000..f32854c9 --- /dev/null +++ b/src/modules/towers/module.js @@ -0,0 +1,755 @@ +// 31-towers P2: TOWERS — twelve levels, a dozen piece shapes, star ratings and an unlock chain +// for the Towers game template ("more than hooray", the Quest note). A CORE module (it ships +// with the app, like pong), dormant in every scene that does not carry the `Towers game` +// marker object. +// +// WHAT LIVES WHERE +// levels.js (pure leaf) the level table, the star rule, unlocks, the rack layout, the tower +// measure, the judge, the wind schedule — vitest-covered +// this file the scene half: find the arena BY NAME (the def owns the geometry), +// deal pieces, judge the round, push gusts, rock the wobble plate, +// publish HUD text through a value node, save progress +// the template def the arena, the piece TEMPLATES (parked under the floor), the HUD +// screens and a small graph: the buttons, P pause, the music, the +// Character Controller (walk + jump) — scripts/author-templates.cjs +// +// WHO DECIDES (golden rule 8: one simulator). Everything that CHANGES the shared game — the +// level, the pieces, the verdict, a gust — is done by ONE authority: the physics initiator +// (29-F's lower-id rule settles a race), else, with no simulation anywhere, the lowest peer id. +// A HUD press does not say who pressed, so every peer sees the stamp and only the authority +// acts on it. Everything else is DERIVED per peer from what already replicates — the game +// singleton (state + `tw*` variables), the pieces (ordinary transient duplicates + moves), +// the synced clock — so this module adds NO message of its own except one: a level picked in +// the game shell's own picker (`api.game.levels`, a local callback) is forwarded to the +// authority. +// +// THE PIECES are TRANSIENT duplicates of the templates (B7's spawner shape): the initiator +// makes them, peers receive the ordinary `duplicate`, they never enter undo, and a stopped +// simulation sweeps them. A level change removes the last level's pieces the same way. +// +// This is a core module, so it reaches the spawner's parts (duplicateObject, physicsAddBody, +// removeTransientObject) and the game state setter through PRIMED dynamic imports — never +// static ones: those modules sit in history's import family and a static edge from here would +// close the TDZ cycle (the moduleSDK rule). `api.spawn` deliberately still does not exist +// (the R29 ruling); a core module is allowed the internals, the pong precedent. + +import { + LEVELS, SHAPES, ZONES, RACKS, STATUS, levelById, nextLevelId, supplyCount, goalHeight, starsFor, + starsText, formatTime, normalizeProgress, mergeResult, isUnlocked, totalStars, layoutSupply, towerPieces, + towerTop, isOutside, outlineCells, cellsFilled, gustAt, gust, windFrom, judge, verdictTitle, statusName, + LOST_AFTER, REST_SPEED +} from './levels.js'; + +/** the marker object that says "this scene is a Towers game" */ +const MARKER = 'Towers game'; +/** the moving markers */ +const GOAL_RING = 'Goal ring'; +const YARD_RING = 'Yard ring'; +const GHOST_WALL = 'Ghost wall'; +/** the Yard ring is authored at this radius; a zone's yard scales it */ +const YARD_RING_RADIUS = 3.4; +/** where a marker waits when the level does not use it */ +const PARKED = [0, -9, 0]; +/** the authority judges ~10x a second */ +const JUDGE_EVERY = 0.1; +/** a button stamp older than this (seconds) when first noticed is history, not a press */ +const FRESH_PRESS = 2.5; +/** a gust is announced this many seconds before it arrives */ +const GUST_WARNING = 1.3; +/** the game variables (the replicated game singleton), all written by the authority only */ +const V = { + level: 'twLevel', + status: 'twStatus', + height: 'twHeight', // the tower top now, centimetres + peak: 'twPeak', // the best this round, centimetres + left: 'twLeft', // pieces still where they were dealt + lost: 'twLost', + hold: 'twHold', // the hold countdown, whole seconds (0 = not holding) + stars: 'twStars', + time: 'twTime', // the finishing time, tenths of a second + used: 'twUsed', // pieces in the finished tower + filled: 'twFilled', // outline cells filled + dealt: 'twDealt' // the round the pieces were dealt for +}; + +export default { + id: 'towers', + name: 'Towers', + version: '2.0.0', + description: + 'The Towers game: twelve levels of stacking with different shapes, limited reach, jumping, wind and a wobbling plate — stars and unlocks saved on this device.', + + /** @param {any} api */ + register(api) { + // ---- primed core internals (see the header) ----------------------------------------- + /** @type {any} */ let gs = null; // gameState + /** @type {any} */ let actions = null; // objectActions + /** @type {any} */ let phys = null; // physics + /** @type {any} */ let transient = null; // transientObjects + /** @type {any} */ let stores = null; // svelte/store get + Promise.all([ + import('../../lib/gameState'), + import('../../lib/objectActions'), + import('../../lib/physics'), + import('../../lib/transientObjects'), + import('svelte/store') + ]).then(([a, b, c, d, e]) => { + gs = a; + actions = b; + phys = c; + transient = d; + stores = e; + }); + const THREE = api.THREE; + + // ---- local state (never replicated: everything shared is a game variable) ----------- + /** hudbutton node id -> the stamp we last acted on (the first-sight rule) */ + /** @type {Map} */ const seenStamps = new Map(); + /** @type {any} */ let memo = null; // the judge's memory, authority only + let memoRound = -1; + let lastJudge = 0; + let lastGustPushed = -1; + let gustRound = -1; + let gustWarned = -1; + let gustBlown = -1; + /** uuid -> when it was first seen resting outside the yard */ + /** @type {Map} */ const outsideSince = new Map(); + /** uuid -> the last known pose, for the poof when a piece disappears, and landing knocks */ + /** @type {Map} */ const lastPose = new Map(); + let announcedRound = -1; + let finishedRound = -1; + let starLostRound = -1; + let progress = normalizeProgress(api.storage?.get?.('progress', null)); + + // ---- reading the world -------------------------------------------------------------- + const group = () => api.objectsGroup(); + /** @param {string} name */ + const byName = (name) => group()?.getObjectByName(name) ?? null; + const active = () => !!byName(MARKER); + const game = () => (gs && stores ? stores.get(gs.gameState) : null); + /** @param {string} name @param {number=} fallback */ + const v = (name, fallback = 0) => { + const n = Number(api.game.getVar(name, fallback)); + return Number.isFinite(n) ? n : fallback; + }; + /** @param {string} name @param {number} value — authority only, and only on change */ + const setV = (name, value) => { + if (v(name, NaN) !== value) api.game.setVar(name, value); + }; + const currentLevel = () => levelById(v(V.level, 0)); + + /** @param {any} object */ + const worldBox = (object) => { + object.updateMatrixWorld(true); + return new THREE.Box3().setFromObject(object); + }; + /** the zone a level builds on, measured @param {any} level */ + const zoneOf = (level) => { + const z = ZONES[level?.zone] ?? ZONES.pad; + const object = byName(z.object); + if (!object) return { center: [0, 0, 0], top: 0.2, footprint: z.footprint, yard: z.yard }; + const box = worldBox(object); + return { + center: [(box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2], + top: box.max.y, + footprint: z.footprint, + yard: z.yard + }; + }; + /** every rack object's world box, by name */ + const rackBoxes = () => { + /** @type {Record} */ + const out = {}; + for (const names of Object.values(RACKS)) + for (const name of names) { + const object = byName(name); + if (!object) continue; + const b = worldBox(object); + out[name] = { min: [b.min.x, b.min.y, b.min.z], max: [b.max.x, b.max.y, b.max.z] }; + } + return out; + }; + /** each shape's template object and world size */ + const templates = () => { + /** @type {Record} */ const objects = {}; + /** @type {Record} */ const sizes = {}; + for (const [shape, def] of Object.entries(SHAPES)) { + const object = byName(def.template); + if (!object) continue; + objects[shape] = object; + const b = worldBox(object); + sizes[shape] = [b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z]; + } + return { objects, sizes }; + }; + /** the live pieces: transient copies of a piece template */ + const pieces = () => + (group()?.children ?? []).filter( + (/** @type {any} */ c) => c.userData?.transient && /^Piece /.test(String(c.name ?? '')) + ); + /** @param {any} object @returns {string} */ + const shapeOf = (object) => { + const base = String(object.name ?? '').replace(/ copy$/, ''); + return Object.keys(SHAPES).find((k) => SHAPES[k].template === base) ?? 'cube'; + }; + + // ---- authority ----------------------------------------------------------------------- + const authority = () => { + if (!phys || !stores) return false; + if (stores.get(phys.simulating)) return phys.isInitiator(); + if (stores.get(phys.remoteSimulating)) return false; + const me = api.peerId(); + if (!me) return true; + const ids = [me, ...api.peerIds()].filter(Boolean).sort(); + return ids[0] === me; + }; + + // ---- the level lifecycle (authority) -------------------------------------------------- + /** move a marker, replicated (`move`) — only when it is not already there + * @param {string} name @param {number[]} pos @param {number[]=} scale */ + const place = (name, pos, scale) => { + const object = byName(name); + if (!object) return; + const same = + Math.hypot(object.position.x - pos[0], object.position.y - pos[1], object.position.z - pos[2]) < 1e-3 && + (!scale || Math.abs(object.scale.x - scale[0]) < 1e-3); + if (!same) api.moveObject(object.uuid, { pos, ...(scale ? { scale } : {}) }); + }; + /** @param {any} level */ + const placeMarkers = (level) => { + const zone = zoneOf(level); + const goal = goalHeight(level); + if (level.goal.type === 'outline') { + place(GOAL_RING, PARKED); + place(GHOST_WALL, [zone.center[0], zone.top + 0.6, zone.center[2]]); + } else { + place(GOAL_RING, [zone.center[0], goal, zone.center[2]]); + place(GHOST_WALL, PARKED); + } + const k = zone.yard / YARD_RING_RADIUS; + place(YARD_RING, [zone.center[0], 0.02, zone.center[2]], [k, k, 1]); + }; + + /** @param {number} id */ + const startLevel = (id) => { + const level = levelById(id); + if (!level || !gs) return false; + if (!isUnlocked(progress, id)) { + api.announce('Level ' + id + ' is locked', { sub: 'Earn a star on level ' + (id - 1) + ' first', ms: 2200, color: '#ffb86b' }); + return false; + } + setV(V.level, id); + setV(V.status, STATUS.playing); + for (const key of [V.height, V.peak, V.lost, V.hold, V.stars, V.time, V.used, V.filled]) setV(key, 0); + setV(V.dealt, -1); + setV(V.left, supplyCount(level)); + // ENTER playing from elsewhere so the round bumps and the clock restarts (setGameState + // is a no-op when already playing — the Restart chain's own reason for a detour) + const state = game()?.state; + if (state === 'playing' || state === 'paused') gs.setGameState('menu', { outcome: '' }); + gs.setGameState('playing', { outcome: '' }); + placeMarkers(level); + return true; + }; + const toMenu = () => { + if (!gs) return; + setV(V.status, STATUS.none); + setV(V.level, 0); + gs.setGameState('menu', { outcome: '' }); + // the level select stands in an empty arena: the last level's pieces go + clearPieces(); + place(GOAL_RING, [0, goalHeight(levelById(1)), 0]); + place(GHOST_WALL, PARKED); + }; + /** remove every live piece (a new level, a restart) */ + const clearPieces = () => { + if (!transient || !phys) return; + for (const object of pieces()) { + phys.physicsRemoveBody?.(object.uuid); + transient.removeTransientObject(object.uuid); + } + }; + /** deal the level's supply onto the racks — the initiator only (it owns the bodies) */ + /** @param {any} level @param {number} round */ + const deal = (level, round) => { + if (!actions || !phys || !phys.isInitiator()) return false; + const { objects, sizes } = templates(); + const slots = layoutSupply(level, rackBoxes(), sizes); + clearPieces(); + for (const slot of slots) { + const template = objects[slot.shape]; + if (!template) continue; + // a slot names where the piece's BOX goes; a building block's origin is on its floor, + // so carry the template's origin-to-centre offset across + const tb = worldBox(template); + const at = [ + slot.pos[0] + template.position.x - (tb.min.x + tb.max.x) / 2, + slot.pos[1] + template.position.y - (tb.min.y + tb.max.y) / 2, + slot.pos[2] + template.position.z - (tb.min.z + tb.max.z) / 2 + ]; + const clone = actions.duplicateObject(template.uuid, { select: false, history: false, transient: true, at }); + if (!clone) continue; + if (slot.yaw) api.moveObject(clone.uuid, { rot: [0, slot.yaw, 0] }); + phys.physicsAddBody(clone.uuid); + } + setV(V.dealt, round); + setV(V.left, slots.length); + return true; + }; + + /** the authority's round judge, ~10x a second */ + /** @param {number} time */ + const judgeRound = (time) => { + const g = game(); + const level = currentLevel(); + if (!g || g.state !== 'playing' || !level || v(V.status) !== STATUS.playing) return; + if (time - lastJudge < JUDGE_EVERY) return; + lastJudge = time; + if (memoRound !== g.round) { + memo = null; + memoRound = g.round; + outsideSince.clear(); + } + // deal once per round (and again if a restarted simulation swept the pieces) + const live = pieces(); + if (v(V.dealt, -1) !== g.round || live.length === 0) { + if (phys?.isInitiator()) deal(level, g.round); + return; + } + if (!phys?.isInitiator()) return; // only the stepping peer knows speeds and holds + const bodies = new Map((phys.physicsDebug?.() ?? []).map((/** @type {any} */ b) => [b.uuid, b])); + const zone = zoneOf(level); + const racks = Object.values(rackBoxes()); + const elapsed = gs.gameElapsed(); + /** @type {any[]} */ + const snapshot = live.map((/** @type {any} */ object) => { + const box = worldBox(object); + const b = bodies.get(object.uuid); + const lv = b?.linvel; + return { + object, + uuid: object.uuid, + shape: shapeOf(object), + pos: [(box.min.x + box.max.x) / 2, (box.min.y + box.max.y) / 2, (box.min.z + box.max.z) / 2], + min: [box.min.x, box.min.y, box.min.z], + max: [box.max.x, box.max.y, box.max.z], + top: box.max.y, + bottom: box.min.y, + speed: lv ? Math.hypot(lv.x, lv.y, lv.z) : 0, + held: !!b?.hold, + dealtAt: object.userData?.towersSlot ?? null + }; + }); + // LOST: resting on the floor outside the yard for a moment — it poofs + let lost = v(V.lost); + let starLost = false; + for (const p of snapshot) { + if (!isOutside(p, zone, racks)) { + outsideSince.delete(p.uuid); + continue; + } + const since = outsideSince.get(p.uuid) ?? elapsed; + outsideSince.set(p.uuid, since); + if (elapsed - since < LOST_AFTER) continue; + outsideSince.delete(p.uuid); + if (p.shape === 'star') starLost = true; + else lost++; + phys.physicsRemoveBody?.(p.uuid); + transient.removeTransientObject(p.uuid); + } + setV(V.lost, lost); + const remaining = snapshot.filter((p) => group()?.getObjectByProperty('uuid', p.uuid)); + const top = towerTop(remaining, zone); + const yardHeld = remaining.some( + (p) => p.held && Math.hypot(p.pos[0] - zone.center[0], p.pos[2] - zone.center[2]) <= zone.yard + ); + const cells = level.goal.type === 'outline' ? outlineCells(zone) : []; + const filled = cells.length ? cellsFilled(cells, remaining) : 0; + const starPiece = remaining.find((p) => p.shape === 'star'); + const tower = towerPieces(remaining, zone); + const star = starPiece + ? { top: starPiece.top, inTower: tower.includes(starPiece) } + : null; + const r = judge(level, { elapsed, top, filled, cells: cells.length, star, anyHeld: yardHeld, lost, starLost }, memo); + memo = r.memo; + setV(V.height, Math.round(top * 100)); + setV(V.peak, Math.round(r.memo.peak * 100)); + setV(V.hold, r.hold); + setV(V.filled, filled); + // pieces still on the racks = resting over a rack box, above it + setV( + V.left, + remaining.filter((p) => racks.some((b) => p.pos[0] >= b.min[0] && p.pos[0] <= b.max[0] && p.pos[2] >= b.min[2] && p.pos[2] <= b.max[2] && p.bottom >= b.max[1] - 0.05)).length + ); + // the wind: the stepping peer pushes; every peer warns and shows it (below) + if (level.wind) { + if (gustRound !== g.round) { + gustRound = g.round; + lastGustPushed = -1; + } + const gNow = gustAt(level, elapsed); + if (gNow && gNow.index > lastGustPushed) { + lastGustPushed = gNow.index; + for (const p of remaining) { + if (p.held || p.top < 0.9) continue; + const mass = Number(p.object.userData?.physics?.mass) || 1; + // higher pieces feel more of it + const k = gNow.strength * mass * Math.min(1.6, 0.5 + p.top / 3); + api.physics.applyImpulse(p.uuid, [gNow.dir[0] * k, 0.05 * k, gNow.dir[2] * k]); + } + } + } + if (r.verdict) finish(level, r.verdict, { elapsed, pieces: tower.length }); + }; + + /** @param {any} level @param {string} verdict @param {{elapsed: number, pieces: number}} result */ + const finish = (level, verdict, result) => { + const won = verdict === 'won'; + const stars = starsFor(level, { won, time: result.elapsed, pieces: result.pieces }); + setV(V.stars, stars); + setV(V.time, Math.round(result.elapsed * 10)); + setV(V.used, result.pieces); + setV(V.hold, 0); + setV(V.status, STATUS[/** @type {keyof typeof STATUS} */ (verdict)] ?? STATUS.time); + gs.setGameState('over', { outcome: verdictTitle(verdict) }); + }; + + // ---- the buttons: every peer watches the stamps, the authority acts ------------------ + /** hudbutton element -> what it does */ + /** @param {string} element */ + const onPress = (element) => { + const level = v(V.level, 0); + const lvl = /^lvl-(\d+)$/.exec(element); + if (lvl) return startLevel(Number(lvl[1])); + if (element === 'retry-btn' || element === 'restart-btn') return level ? startLevel(level) : false; + if (element === 'next-btn') { + const next = nextLevelId(level); + if (next && isUnlocked(progress, next)) return startLevel(next); + if (!next) { + api.announce('That was the last level', { sub: 'Every level is yours — try for three stars', ms: 2400, color: '#ffd45e' }); + return toMenu(); + } + api.announce('Level ' + next + ' is locked', { sub: 'Win this level to open it', ms: 2200, color: '#ffb86b' }); + return false; + } + if (element === 'levels-btn' || element === 'quit-btn') return toMenu(); + return false; + }; + /** each peer: find FRESH presses; the authority applies them */ + const watchButtons = () => { + const amAuthority = authority(); + for (const node of api.flow.nodes('hudbutton')) { + const element = String(node.data?.element ?? ''); + if (!/^(lvl-\d+|next-btn|retry-btn|levels-btn|restart-btn|quit-btn)$/.test(element)) continue; + const entry = api.flow.triggerStamp(node.id); + const stamp = Number(entry?.stamp) || 0; + // FIRST SIGHT: whatever the node already carries is history, never a press + if (!seenStamps.has(node.id)) { + seenStamps.set(node.id, stamp); + continue; + } + if (stamp === seenStamps.get(node.id)) continue; + // ...and so is a stamp that ARRIVES old (a joiner's trigger-log reply) + const fresh = Number(entry?.age ?? 0) < FRESH_PRESS; + seenStamps.set(node.id, stamp); + // a locked level says so on the peer that sees it, whoever decides + const lvl = /^lvl-(\d+)$/.exec(element); + if (!fresh) continue; + if (lvl && !amAuthority && !isUnlocked(progress, Number(lvl[1]))) + api.announce('Level ' + lvl[1] + ' is locked', { sub: 'Earn a star on level ' + (Number(lvl[1]) - 1) + ' first', ms: 2200, color: '#ffb86b' }); + if (amAuthority) onPress(element); + } + }; + + // ---- every peer: the moments --------------------------------------------------------- + /** @param {number} time */ + const moments = (time) => { + const g = game(); + const level = currentLevel(); + if (!g || !level) return; + const status = v(V.status); + // a round starts: the intro banner + if (g.state === 'playing' && status === STATUS.playing && announcedRound !== g.round) { + announcedRound = g.round; + finishedRound = -1; + const goal = level.goal.type === 'outline' ? 'Fill the ghost wall' : level.goal.type === 'deliver' ? 'The star on top at ' + goalHeight(level).toFixed(1) + ' m' : 'Tower to ' + goalHeight(level).toFixed(1) + ' m'; + api.announce('Level ' + level.id + ' · ' + level.name, { sub: goal + ' — ' + level.intro, ms: 4200, color: '#ffd45e' }); + api.playSound('whistle'); + } + // the wind: warn, then show it + if (g.state === 'playing' && level.wind && gs) { + const elapsed = gs.gameElapsed(); + const current = gustAt(level, elapsed); + const nextIndex = current ? current.index + 1 : 0; + const next = gust(level, nextIndex); + if (next.at - elapsed <= GUST_WARNING && gustWarned < nextIndex + g.round * 1000) { + gustWarned = nextIndex + g.round * 1000; + api.announce('Gust from the ' + windFrom(next.dir) + '!', { ms: 1300, color: '#9ee6ff' }); + } + if (current && gustBlown < current.index + g.round * 1000) { + gustBlown = current.index + g.round * 1000; + api.playSound('whoosh'); + const zone = zoneOf(level); + api.effects.burst([zone.center[0] - current.dir[0] * 1.5, Math.max(1, v(V.height) / 100), zone.center[2] - current.dir[2] * 1.5], { kind: 'smoke', count: 48 }); + } + } + // a round ends: stars saved on THIS device (every peer earns them), the moment + if (g.state === 'over' && finishedRound !== g.round && status >= STATUS.won) { + finishedRound = g.round; + if (status === STATUS.won) { + const stars = v(V.stars); + progress = mergeResult(progress, level.id, { stars, time: v(V.time) / 10, pieces: v(V.used) }); + api.storage?.set?.('progress', progress); + publishLevels(); + api.playSound('levelup'); + api.hapticPattern('success'); + const zone = zoneOf(level); + api.effects.burst([zone.center[0], Math.max(1.2, v(V.height) / 100), zone.center[2]], { kind: 'confetti', count: 96 }); + } else { + api.playSound('fail'); + api.hapticPattern('fail'); + } + } + // pieces: a poof where one vanished mid-round, a knock where one landed + const now = pieces(); + const alive = new Set(now.map((/** @type {any} */ o) => o.uuid)); + for (const [uuid, pose] of lastPose) { + if (alive.has(uuid)) continue; + lastPose.delete(uuid); + if (g.state === 'playing' && time - pose.t < 1) { + api.effects.burst(pose.pos, { kind: 'smoke', count: 36 }); + api.playSound('pop', pose.pos); + } + } + for (const object of now) { + const p = object.position; + const was = lastPose.get(object.uuid); + const dt = was ? Math.max(1e-3, time - was.t) : 0; + const vy = was ? (p.y - was.pos[1]) / dt : 0; + if (was && was.vy < -2.4 && vy > -0.6 && g.state === 'playing') api.playSound('hit', [p.x, p.y, p.z]); + lastPose.set(object.uuid, { pos: [p.x, p.y, p.z], vy, t: time }); + } + }; + + // ---- the HUD's words: one value node, every peer computes the same line --------------- + /** @param {any} data @returns {any} */ + const info = (data) => { + const read = String(data?.read ?? 'title'); + const level = currentLevel(); + if (read === 'levelStars') { + const id = Number(data?.level) || 0; + if (!levelById(id)) return ''; + if (!isUnlocked(progress, id)) return '🔒 locked'; + const row = progress.levels[String(id)]; + return row ? starsText(row.stars) : '☆☆☆'; + } + if (read === 'levelName') { + const l = levelById(Number(data?.level) || 0); + return l ? l.id + ' · ' + l.name : ''; + } + if (read === 'menuLine') return 'Stars earned: ' + totalStars(progress) + ' / ' + LEVELS.length * 3; + if (!level) return read === 'progress' || read === 'height' || read === 'goal' ? 0 : ''; + const goal = goalHeight(level); + const height = v(V.height) / 100; + switch (read) { + case 'title': + return 'Level ' + level.id + ' · ' + level.name; + case 'goalText': + // short: the VR strip keeps lines of 28 characters or fewer + if (level.goal.type === 'outline') return 'Ghost wall ' + v(V.filled) + ' / 6 filled'; + if (level.goal.type === 'deliver') return 'Star on top · ' + goal.toFixed(1) + ' m'; + return 'Goal ' + goal.toFixed(1) + ' m · now ' + height.toFixed(1) + ' m'; + case 'goal': + return goal; + case 'height': + return height; + case 'progress': + return level.goal.type === 'outline' ? v(V.filled) / 6 : goal > 0 ? Math.min(1, height / goal) : 0; + case 'pieces': + return 'Rack ' + v(V.left) + ' · lost ' + v(V.lost) + '/' + level.lost; + case 'clock': { + const g = game(); + const elapsed = g?.state === 'playing' && gs ? gs.gameElapsed() : 0; + return formatTime(level.limit - elapsed); + } + case 'hold': { + const n = v(V.hold); + return n > 0 ? 'Hold steady… ' + n : ''; + } + case 'rule': + return level.rule ?? 'Reach is limited to your arm — carry pieces close. Jump: Space / A.'; + case 'result': + return verdictTitle(statusName(v(V.status))); + case 'resultStars': + return v(V.status) === STATUS.won ? starsText(v(V.stars)) : '☆☆☆'; + case 'resultLine': { + if (v(V.status) !== STATUS.won) { + const why = { time: 'The clock ran out.', fell: 'It came down — build wider at the bottom.', lost: 'Too many pieces fell outside the yard.', star: 'The star fell outside the yard.' }[statusName(v(V.status))] ?? ''; + return why + ' Best tower ' + (v(V.peak) / 100).toFixed(1) + ' m.'; + } + return 'Time ' + formatTime(v(V.time) / 10) + ' (par ' + formatTime(level.par.time) + ') · ' + v(V.used) + ' pieces (par ' + level.par.pieces + ')'; + } + case 'resultBest': { + const row = progress.levels[String(level.id)]; + return row ? 'Best on this device: ' + starsText(row.stars) + ' ' + formatTime(row.time) : ''; + } + default: + return ''; + } + }; + // an EMPTY string would reach HUD Text as an empty format, which prints the value ("0"); + // a space draws nothing and the VR overlay drops whitespace-only lines + api.registerValueNode( + 'towersinfo', + (/** @type {any} */ data) => { + const out = info(data); + return out === '' ? ' ' : out; + }, + { vtype: 'any' } + ); + + // ---- the wobble plate: a module effect, so the runtime makes it KINEMATIC ------------- + api.registerEffect('towerswobble', (/** @type {any} */ object, /** @type {any} */ base, /** @type {any} */ data, /** @type {number} */ time) => { + const level = currentLevel(); + const on = level?.zone === 'wobble' && game()?.state === 'playing'; + const amp = on ? Number(data?.amplitude ?? 0.07) || 0 : 0; + const period = Math.max(1, Number(data?.period ?? 3.4) || 3.4); + // the runtime's base is {pos, rot: [x, y, z], scale} — plain arrays + const rot = base?.rot ?? [0, 0, 0]; + object.rotation.x = rot[0] + amp * Math.sin((2 * Math.PI * time) / period); + object.rotation.z = rot[2] + amp * 0.7 * Math.sin((2 * Math.PI * time) / (period * 1.37) + 1.1); + }); + + api.registerNodeGroup({ + group: 'Towers', + items: [ + { + type: 'towersinfo', + label: 'Towers info', + defaults: { read: 'title', level: 1 }, + params: [ + { key: 'read', kind: 'select', options: ['title', 'goalText', 'goal', 'height', 'progress', 'pieces', 'clock', 'hold', 'rule', 'result', 'resultStars', 'resultLine', 'resultBest', 'levelName', 'levelStars', 'menuLine'] }, + { key: 'level', kind: 'range', min: 1, max: LEVELS.length, step: 1 } + ] + }, + { + type: 'towerswobble', + label: 'Towers wobble', + defaults: { amplitude: 0.07, period: 3.4 }, + params: [ + { key: 'amplitude', kind: 'range', min: 0, max: 0.2, step: 0.01 }, + { key: 'period', kind: 'range', min: 1, max: 10, step: 0.1 } + ] + } + ] + }); + + // ---- the game shell's level picker, when the app has one (31-game-shell) ------------- + /** @type {any} */ let levelsOff = null; + const publishLevels = () => { + if (typeof api.game?.levels !== 'function' || !active()) return; + try { + levelsOff = api.game.levels({ + list: LEVELS.map((l) => ({ + id: String(l.id), + label: l.id + ' · ' + l.name, + locked: !isUnlocked(progress, l.id), + stars: progress.levels[String(l.id)]?.stars ?? 0 + })), + current: v(V.level, 0) ? String(v(V.level, 0)) : undefined, + onPick: (/** @type {any} */ id) => pick(Number(id)) + }); + } catch (error) { + console.log('towers: level picker', error); + } + }; + /** a level chosen on THIS peer outside the HUD (the shell's picker, its Restart) — the + * authority applies it, so a non-authority forwards it @param {number} id */ + const pick = (id) => { + if (!levelById(id)) return; + if (authority()) startLevel(id); + else api.send({ op: 'pick', level: id }); + }; + // the shell's Restart restarts the level you are on; its How to play is ours + if (typeof api.game?.onRestart === 'function') + api.game.onRestart(() => { + if (active() && v(V.level, 0)) pick(v(V.level, 0)); + }); + const HELP = [ + 'Build a tower to the gold ring. Carry pieces from the racks to the glowing zone.', + 'You can only grab what is close to your body (about 1.3 m): build steps, climb them, and JUMP (Space / A) to reach higher.', + 'A finished tower must hold still for 3 seconds. Pieces left outside the glowing yard are lost.', + 'Stars: one for finishing, one for par pieces, one for par time. A star opens the next level.' + ]; + /** @type {null | (() => void)} */ let helpOff = null; + api.onMessage((/** @type {any} */ msg) => { + if (msg?.op === 'pick' && authority()) startLevel(Number(msg.level)); + }); + + // ---- the frame -------------------------------------------------------------------------- + let wasActive = false; + api.registerFrameTask((/** @type {number} */ time) => { + if (!gs || !stores) return; + const on = active(); + if (on !== wasActive) { + wasActive = on; + if (on) publishLevels(); + if (on && typeof api.game?.setHelp === 'function') helpOff = api.game.setHelp(HELP); + if (!on && helpOff) { + helpOff(); + helpOff = null; + } + if (!on && typeof levelsOff === 'function') { + levelsOff(); + levelsOff = null; + } + } + if (!on) return; + watchButtons(); + if (authority()) judgeRound(time); + moments(time); + }); + api.onSceneClear(() => { + seenStamps.clear(); + lastPose.clear(); + outsideSince.clear(); + memo = null; + announcedRound = -1; + finishedRound = -1; + wasActive = false; + }); + + // the suites' window onto the module (read-only, plus a scripted start) + /** @type {any} */ (globalThis).__towers = { + levels: LEVELS, + progress: () => progress, + resetProgress: () => { + progress = normalizeProgress(null); + api.storage?.remove?.('progress'); + publishLevels(); + }, + /** a suite's shortcut to a progress state, written through api.storage like a win */ + setProgress: (/** @type {any} */ raw) => { + progress = normalizeProgress(raw); + api.storage?.set?.('progress', progress); + publishLevels(); + return progress; + }, + reloadProgress: () => { + progress = normalizeProgress(api.storage?.get?.('progress', null)); + return progress; + }, + authority, + pieces: () => pieces().map((/** @type {any} */ o) => ({ uuid: o.uuid, name: o.name, shape: shapeOf(o), pos: o.position.toArray() })), + info, + vars: () => Object.fromEntries(Object.values(V).map((k) => [k, api.game.getVar(k, null)])), + zone: () => zoneOf(currentLevel()), + startLevel, + /** evidence shots: end the running level with a verdict, the judge's own path */ + finishNow: (/** @type {string} */ verdict) => { + const level = currentLevel(); + if (level && gs && game()?.state === 'playing') finish(level, verdict, { elapsed: gs.gameElapsed(), pieces: Math.min(level.par.pieces, supplyCount(level)) }); + } + }; + } +}; diff --git a/tests/e2e/game-towers.test.cjs b/tests/e2e/game-towers.test.cjs index 27ad9279..d2a8d57e 100644 --- a/tests/e2e/game-towers.test.cjs +++ b/tests/e2e/game-towers.test.cjs @@ -1,15 +1,15 @@ -// B8 ACCEPTANCE — the Towers game template (redesigned after the first playthrough: -// PRE-PLACED crates, no spawner, lit daylight, a pause/restart menu). Loaded from the -// REAL .tpscene in the sibling scenes checkout (or TOWERS_TPSCENE=, a lane's staged -// build) with the REAL collectible zip, driven through the real surfaces. Skip-never-fail -// when the scenes checkout or the zip is absent — authored content, not core code, must -// keep a bare checkout green. +// B8 → 31-towers ACCEPTANCE — the Towers game template, loaded from the REAL .tpscene in the +// sibling scenes checkout (or TOWERS_TPSCENE=, a lane's staged build) and driven through +// the real surfaces. Skip-never-fail when the scenes checkout is absent — authored content, not +// core code, must keep a bare checkout green. // -// 30 visuals-core: the finished look (glowing rings with select-through, a tiled floor from -// a shader graph, wooden crates, a sky + ground, stars that turn and breathe once a round -// starts), a centred HUD, a readable frame in play, and the BEST HEIGHT saved on this device -// (Store Value max) and shown on the menu, with the Round-over screen reading this round's -// height from `towers-last` — a perRound latch reads un-set the moment the round ends. +// 31-towers: Towers is TWELVE LEVELS now (the core `towers` module: levels, pieces, stars, +// unlocks — towers-levels plays them). This suite owns the SCENE: the arena every level names +// (three build zones, the racks, the high ledge, the star perch, the moving markers, the piece +// templates parked under the floor), the look, the play block (grab REACH + the spawn), the +// player's body (a walk + jump Character Controller), the level-select menu and a readable +// frame. No module download any more: the collectible stars are gone and the template needs +// only core. const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); @@ -28,12 +28,6 @@ h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); const page = A.page; - if (!(await h.installModule(A, 'collectible'))) { - console.log('SKIP: no collectible.zip in the sibling modules checkout (npm run pack -- collectible)'); - await h.finish(browser); - return; - } - const bytes = Array.from(fs.readFileSync(TPSCENE)); await page.evaluate(async (arr) => { const s = window.__stores; @@ -45,17 +39,11 @@ h.run(async () => { const snap = () => page.evaluate(() => { const s = window.__stores; - /** @param {any} st */ - const g = (st) => { let v; st.subscribe((/** @type {any} */ x) => (v = x))(); return v; }; - let group; - s.objectsGroup.subscribe((/** @type {any} */ v) => (group = v))(); - const kids = group.children.map((/** @type {any} */ c) => ({ - name: c.name, uuid: c.uuid, - dynamic: c.userData?.physics?.mode === 'dynamic', - y: +c.position.y.toFixed(2) - })); + const g = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; + const group = g(s.objectsGroup); return { - kids, + names: group.children.map((c) => c.name), + dynamic: group.children.filter((c) => c.userData?.physics?.mode === 'dynamic').map((c) => ({ name: c.name, y: c.position.y })), sim: !!g(s.physics.simulating), state: g(s.gameState.gameState)?.state ?? null, play: g(s.scenePhysics.scenePlay), @@ -63,109 +51,88 @@ h.run(async () => { }; }); const hud = async () => (await page.locator('#hud-layer').textContent()) ?? ''; - // 30b: the game-feel stores the suite reads (sound / burst / banner / music) - const feel = () => - page.evaluate(() => { - const k = window.__stores.gameKit; - let music; - k.gameMusic.gameMusicState.subscribe((v) => (music = v))(); - const d = k.gameFeelActions.gameFeelActionsDebug(); - return { music: music?.preset ?? null, last: d.last, fired: d.fired, sounds: d.sounds }; - }); - const resetFeel = () => page.evaluate(() => window.__stores.gameKit.gameFeelActions.resetGameFeelActionsDebug()); - const clickBtn = (text) => page.locator('#hud-layer button', { hasText: text }).click(); - // 1 — the world arrived whole and lit + // 1 — the arena every level names arrived, and nothing loose on the floor let st = await snap(); - // 30: 24 -> 36 (4 wall trims, the pad rim + its light, the height pole + 4 marks, the card camera) - h.check(st.kids.length === 36, `the 36 objects arrived (${st.kids.length})`); - const crates = st.kids.filter((k) => k.dynamic); - h.check(crates.length === 9, `9 pre-placed dynamic crates (${crates.length})`); - h.check(st.play?.simOnPlay === true && st.play?.interaction === 'grab', 'play block: grab + simOnPlay'); - h.check(st.state === 'menu' && st.screen === 'menu', `starts on the menu screen (${st.state}/${st.screen})`); + if (!st.names.includes('Towers game')) { + console.log('SKIP: this Towers scene predates 31 (no "Towers game" marker) — author it or set TOWERS_TPSCENE'); + await h.finish(browser); + return; + } + const need = ['Build pad', 'Pedestal', 'Wobble plate', 'Rack west', 'Rack east', 'High ledge', 'Star perch', 'Goal ring', 'Yard ring', 'Ghost wall', 'Template vault', 'Card camera']; + h.check(need.every((n) => st.names.includes(n)), `the arena: zones, racks, ledge, perch, markers (${need.filter((n) => !st.names.includes(n)).join(', ') || 'all present'})`); + const templates = ['cube', 'plank', 'beam', 'wedge', 'barrel', 'arch', 'L', 'ball', 'base', 'star'].map((k) => 'Piece ' + k); + h.check(templates.every((n) => st.dynamic.some((d) => d.name === n && d.y < -5)), 'ten piece TEMPLATES, dynamic, parked on the vault under the floor'); + h.check(st.dynamic.length === templates.length, `and no other dynamic object — the pieces are dealt per level (${st.dynamic.length})`); + h.check(st.play?.simOnPlay === true && st.play?.interaction === 'grab' && st.play?.reach === 1.3, `play block: grab, simOnPlay, reach 1.3 m (${JSON.stringify(st.play)})`); + h.check(st.state === 'menu' && st.screen === 'menu', `starts on the level select (${st.state}/${st.screen})`); + const shapes = await page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const arch = g.getObjectByName('Piece arch'); + const l = g.getObjectByName('Piece L'); + const wedge = g.getObjectByName('Piece wedge'); + return { + arch: arch?.userData?.physics?.collider + ':' + (arch?.userData?.physics?.colliderPieces?.length ?? 0), + l: l?.userData?.physics?.collider + ':' + (l?.userData?.physics?.colliderPieces?.length ?? 0), + wedge: wedge?.userData?.physics?.collider, + wedgeGeo: wedge?.geometry?.type + }; + }); + h.check(shapes.arch === 'custom:3' && shapes.l === 'custom:2' && shapes.wedge === 'hull', `real shapes collide as themselves: arch 3 boxes, L 2 boxes, wedge a hull (${JSON.stringify(shapes)})`); - // 1b — 30 visuals-core: the finished look is DATA in the file, measured rather than seen + // 1b — the look, measured const look = await page.evaluate(() => { const s = window.__stores; const g = (st) => { let v; st.subscribe((x) => (v = x))(); return v; }; - const group = g(s.objectsGroup); - const ring = group.getObjectByName('Height ring 2m'); - const floor = group.getObjectByName('Arena floor'); - const crate = group.getObjectByName('Cube 1'); - const star = group.getObjectByName('Star 1'); + const floor = g(s.objectsGroup).getObjectByName('Arena floor'); const env = g(s.environment.environment); - const anims = g(s.animationPreview.animations); - const clip = anims[star?.uuid]; return { - ring: ring && { geo: ring.geometry.type, pick: ring.userData.pick ?? null, sensor: !!ring.userData.physics?.sensor, collider: ring.userData.physics?.collider }, floorShader: !!g(s.shaderGraph.shaderGraphs)[floor?.uuid] && !g(s.shaderGraph.shaderErrors)[floor?.uuid], - floorPatched: !!floor && Object.prototype.hasOwnProperty.call(floor.material, 'onBeforeCompile'), - crate: crate && { type: crate.material.type, sheen: crate.material.sheen ?? 0, verts: crate.geometry.attributes.position.count }, - starClip: clip ? Object.values(clip.clips).map((c) => c.name) : [], gradient: !!env?.customPreset?.gradient, ground: !!env?.customPreset?.ground, exposure: env?.exposure, post: (g(s.scenePost.scenePost)?.effects ?? []).map((e) => e.kind) }; }); - h.check(look.ring?.geo === 'TorusGeometry' && look.ring.pick === 'through' && look.ring.sensor && look.ring.collider === 'box', `the height markers are thin glowing ring SENSORS, select-through (${JSON.stringify(look.ring)})`); - h.check(look.floorShader && look.floorPatched, `the arena floor carries its tile shader graph, compiled and installed (${look.floorShader}/${look.floorPatched})`); - h.check(look.crate?.type === 'MeshPhysicalMaterial' && look.crate.sheen > 0 && look.crate.verts > 36, `the crates are chamfered wood: physical + sheen (${JSON.stringify(look.crate)})`); - h.check(look.starClip.includes('Star glow'), `every star carries the Turntable + Pulse clip (${look.starClip})`); + h.check(look.floorShader, 'the arena floor carries its tile shader graph'); h.check(look.gradient && look.ground && look.exposure >= 0.9, `a real sky and ground, exposure >= 0.9 (${look.gradient}/${look.ground}/${look.exposure})`); h.check(['ao', 'tonemapping', 'bloom', 'smaa'].every((k) => look.post.includes(k)), `the post floor: AO, tone mapping, bloom, SMAA (${look.post})`); - // no toast on load (the Stars Room emitter-cap toast was the finding) const loadToasts = await page.locator('.tp-toast').allTextContents().catch(() => []); - h.check(!loadToasts.some((t) => /cap|error|went wrong/i.test(t)), `no warning toast on load (${JSON.stringify(loadToasts).slice(0, 120)})`); + h.check(!loadToasts.some((t) => /cap|error|went wrong|install|module/i.test(t)), `no warning toast on load — no module to download (${JSON.stringify(loadToasts).slice(0, 120)})`); - // 2 — entering play starts the sim (simOnPlay honoured from the file) + // 2 — Play: the sim, the body, the menu, the music, the spawn await page.evaluate(() => window.__stores.isLocked.set(true)); await h.eventually(() => snap().then((v) => v.sim), (v) => v === true, 'entering play starts the sim', 10000); - // 30 P1: a game's menu is drawn in PLAY, not over the editor (game-editor-flow covers - // the editor half), so the menu check lives on this side of the play press now - h.check(/TOWERS/.test(await hud()), 'the menu renders in play (TOWERS)'); - h.check(/Your best tower: 0 m/.test(await hud()), 'the menu shows this device\'s best height (none yet: 0 m)'); - // 30b: HOW TO PLAY on the menu, the arcade music in Play, and Play put the eye on the spawn - h.check(/HOW TO PLAY/.test(await hud()) && /gold ring at 4 m is the top/.test(await hud()) && /VR: grip grabs a crate/.test(await hud()), '30b: the menu says how to play, on a desktop and in VR'); - await h.eventually(() => feel().then((f) => f.music), (m) => m === 'arcade', '30b: the arcade music plays in Play', 6000); + const body = await page.evaluate(() => window.__stores.charController.charControllerDebug().control); + h.check(body?.mode === 'walk' && body.jumpHeight === 1 && body.gravity, `the player WALKS and JUMPS 1 m (a Character Controller: ${JSON.stringify(body)})`); + await h.eventually(hud, (t) => /TOWERS/.test(t) && /1 · Stack/.test(t) && /12 · Summit/.test(t), 'the level select renders in play: twelve levels', 6000); + h.check(/build steps and jump/.test(await hud()) && /Space jumps/.test(await hud()) && /A jumps/.test(await hud()), 'the menu says how to play — reach, steps, jump — on a desktop and in VR'); + await h.eventually(() => page.evaluate(() => { let m; window.__stores.gameKit.gameMusic.gameMusicState.subscribe((v) => (m = v))(); return m?.preset ?? null; }), (m) => m === 'arcade', 'the arcade music plays in Play', 6000); const eye = await page.evaluate(() => { const s = window.__stores; let cam; s.playerCam.subscribe((v) => (cam = v))(); const p = cam.getWorldPosition(new s.THREE.Vector3()); return [p.x, p.y, p.z].map((n) => +n.toFixed(2)); }); - h.check(Math.abs(eye[0]) < 0.05 && Math.abs(eye[1] - 1.7) < 0.05 && Math.abs(eye[2] - 4.5) < 0.05, `30b: Play put the eye on the spawn in front of the pad (${eye})`); - // 30: a centred HUD text is CENTRED — the words, not the box (a flex row shrank the line - // to its text, so text-align did nothing and every centred title sat flush left) + h.check(Math.abs(eye[0]) < 0.05 && Math.abs(eye[1] - 1.7) < 0.1 && Math.abs(eye[2] - 5.4) < 0.1, `Play put the eye on the spawn between the racks (${eye})`); const centring = await page.evaluate(() => { const out = {}; for (const el of document.querySelectorAll('#hud-layer .hud-text')) { const t = el.textContent.trim(); - if (t !== 'TOWERS' && !/^Your best tower/.test(t)) continue; + if (t !== 'TOWERS') continue; const r = document.createRange(); r.selectNodeContents(el); const words = r.getBoundingClientRect(); const box = el.getBoundingClientRect(); - out[t.slice(0, 12)] = +((words.left + words.width / 2) - (box.left + box.width / 2)).toFixed(1); + out[t] = +((words.left + words.width / 2) - (box.left + box.width / 2)).toFixed(1); } return out; }); - h.check(Object.keys(centring).length === 2 && Object.values(centring).every((d) => Math.abs(d) < 3), `centred HUD texts are centred on their box (offsets ${JSON.stringify(centring)})`); + h.check(Object.keys(centring).length === 1 && Math.abs(centring.TOWERS) < 3, `the title is centred on its box (${JSON.stringify(centring)})`); - // 3 — the Start button flips to playing and swaps the menu for the HUD - await resetFeel(); - await clickBtn('Start round'); - await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'Start flips to playing', 8000); - await h.eventually(() => feel(), (f) => (f.sounds.click ?? 0) >= 1 && (f.sounds.whistle ?? 0) >= 1 && f.last.some((e) => e.type === 'announce' && e.text === 'Build!'), '30b: Start clicks, whistles and says "Build!"', 4000); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'hud', 'the in-game HUD screen shows', 6000); - // 30: the stars turn and breathe once the round starts (a Play Animation per star) - await h.eventually( - () => page.evaluate(() => { - const s = window.__stores; let group, pb; - s.objectsGroup.subscribe((v) => (group = v))(); s.animationPreview.playback.subscribe((v) => (pb = v))(); - return ['Star 1', 'Star 2', 'Star 3'].filter((n) => pb[group.getObjectByName(n)?.uuid]?.playing).length; - }), - (n) => n === 3, 'Start sets all three stars turning (their Star glow clip plays)', 6000 - ); - // 30: a READABLE frame in play — mean luminance of the centred 360 px square (fork 11) + // 3 — a level starts from its button; the frame reads + await page.locator('#hud-layer button', { hasText: '1 · Stack' }).first().click(); + await h.eventually(() => snap().then((v) => v.state + '/' + v.screen), (v) => v === 'playing/hud', 'the Stack button starts level 1 on the in-game HUD', 8000); + await h.eventually(() => page.evaluate(() => window.__towers.pieces().length), (n) => n === 6, 'the level deals its six cubes', 8000); await page.waitForTimeout(800); const lum = await page.evaluate(async (b64) => { const bmp = await createImageBitmap(await (await fetch('data:image/png;base64,' + b64)).blob()); @@ -177,155 +144,14 @@ h.run(async () => { return sum / (d.length / 4) / 255; }, (await page.screenshot()).toString('base64')); h.check(lum >= 0.25, `the play frame reads: centre luminance ${lum.toFixed(3)} >= 0.25`); - - // 4 — crates rest stably (no cycling): population and dynamic count hold over 4s - const before = (await snap()).kids.length; - await page.waitForTimeout(4000); - st = await snap(); - h.check(st.kids.length === before, `object count is stable, no spawn churn (${before} -> ${st.kids.length})`); - h.check(st.kids.filter((k) => k.dynamic).length === 9, 'still exactly 9 crates (none recycled)'); - - // 5 — a crate lifted through the rings latches the height HUD (grab stand-in: - // an external write carries a crate up through the ring sensors) - const crate = st.kids.find((k) => k.dynamic); - await page.evaluate((uuid) => { - const s = window.__stores; - let group; s.objectsGroup.subscribe((/** @type {any} */ v) => (group = v))(); - const o = group.children.find((/** @type {any} */ c) => c.uuid === uuid); - o.position.set(0, 3.5, 0); o.updateMatrix(); - }, crate.uuid); - await h.eventually(async () => await hud(), (t) => /Best height: [1-3] m/.test(t), 'lifting a crate latches the height HUD', 12000); - // 30b: every ring the crate crossed is a MILESTONE — a banner, a burst at the ring, its - // chime, and a buzz for a player in VR - await h.eventually( - () => feel(), - (f) => [1, 2, 3].every((i) => f.last.some((e) => e.type === 'announce' && e.text === 'Ring ' + i + ' reached')), - '30b: rings 1, 2 and 3 are announced as they are reached', 6000 - ); - const rings = await feel(); - const bursts = rings.last.filter((e) => e.type === 'effectburst' && e.kind === 'sparkle' && e.fired); - h.check([1, 2, 3].every((y) => bursts.some((b) => Math.abs(b.where[1] - y) < 0.05 && Math.hypot(b.where[0], b.where[2]) < 0.05)), `30b: a sparkle burst AT each ring (${JSON.stringify(bursts.map((b) => b.where.map((n) => +n.toFixed(2))))})`); - h.check((rings.sounds.ring ?? 0) === 3 && rings.last.filter((e) => e.sound === 'ring').every((e) => e.spatial), `30b: each ring chimes once, from the ring (${rings.sounds.ring})`); - h.check((rings.fired.hapticpulse ?? 0) >= 1 && rings.last.some((e) => e.type === 'hapticpulse' && e.pattern === 'success' && e.felt), `30b: and asks the success buzz, felt in Play (${rings.fired.hapticpulse})`); - // a crate re-entering ring 1 in the same round is not a new milestone - await page.evaluate(() => { - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - window.__stores.flowRuntime.fireObjectEnter(group.getObjectByName('Height ring 1m').uuid, group.getObjectByName('Cube 2').uuid); - }); - await page.waitForTimeout(700); - const re = await feel(); - h.check(re.last.filter((e) => e.type === 'announce' && e.text === 'Ring 1 reached').length === 1 && (re.sounds.ring ?? 0) === 3, '30b: a second crossing of ring 1 in the same round says nothing (a perRound Once)'); - // THE TOP: the gold ring is the big one - await page.evaluate((uuid) => { - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - const o = group.children.find((c) => c.uuid === uuid); - o.position.set(0, 4.4, 0); o.updateMatrix(); - }, crate.uuid); - await h.eventually(() => feel(), (f) => f.last.some((e) => e.type === 'announce' && e.text === 'Top of the tower!') && (f.sounds.levelup ?? 0) === 1 && f.last.some((e) => e.type === 'effectburst' && e.kind === 'confetti' && Math.abs(e.where[1] - 4) < 0.05), '30b: the gold ring: "Top of the tower!", confetti at the ring and the level-up fanfare', 8000); - // ...and when that crate comes down on the pad it lands with a knock (On Impact -> Game Sound) - await h.eventually(() => feel(), (f) => f.last.some((e) => e.sound === 'hit' && e.spatial), '30b: a falling crate lands with a knock, at the crate', 8000); - h.check(/Stars left: 3/.test(await hud()), 'the collectible module reports 3 stars left'); - // 30: the crossing SAVED the height on this device (Store Value max + the round's `set`) - const stored = () => page.evaluate(() => { - const out = {}; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (/^tp:scene:.*:towers-(best|last)$/.test(k)) out[k.split(':').pop().replace('towers-', '')] = JSON.parse(localStorage.getItem(k)); - } - return out; - }); - await h.eventually(stored, (v) => v.best >= 1 && v.last === v.best, 'the best height is saved on this device (towers-best, towers-last)', 6000); - h.check(/\d+s/.test(await hud()), 'the round clock renders'); - - // 5b — GRAB: aim at a crate and pointerdown carries it (the user's "cannot take - // objects with mouse click"). The ray is NDC (0,0), so put a crate in front of the - // aiming camera; the sim is already running from Start. - const grabCrate = (await snap()).kids.find((k) => k.dynamic).uuid; - await page.evaluate((uuid) => { - const THREE = window.__stores.THREE; - let camera; window.__stores.globalCamera.subscribe((v) => (camera = v))(); - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - const at = camera.getWorldPosition(new THREE.Vector3()) - .addScaledVector(camera.getWorldDirection(new THREE.Vector3()), 3.5); - const o = group.getObjectByProperty('uuid', uuid); - o.position.copy(at); o.updateMatrixWorld(); window.__stores.objectsGroup.update((v) => v); - }, grabCrate); - await page.waitForTimeout(500); - const aiming = await page.evaluate(() => { - let s; window.__stores.playInteract.playInteractState.subscribe((v) => (s = v))(); return s; - }); - h.check(aiming.mode === 'aiming', `the reticle finds a grabbable crate (${aiming.mode})`); - await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true }))); - await page.waitForTimeout(300); - const carrying = await page.evaluate(() => { - let d; window.__stores.playInteract.playInteractState.subscribe((v) => (d = v))(); - return window.__stores.playInteract.playInteractDebug().carrying; - }); - h.check(carrying === grabCrate, `pointerdown grabs the crate (${carrying === grabCrate})`); - // 30b: the lift makes a sound, placed at the crate (On Grab -> Game Sound) - await h.eventually(() => feel(), (f) => f.last.some((e) => e.sound === 'pop' && e.spatial), '30b: grabbing the crate pops, at the crate', 3000); - await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true }))); - await page.waitForTimeout(300); - - // 5c — 30: Round over reads THIS round's height from the stored `towers-last` (a perRound - // latch reads 0 the instant the round ends, which is what the old line showed) and the - // best from `towers-best`; Play again starts a fresh round and re-zeroes the round's value - const kept = await stored(); - // clear the pad first: a crate still standing through a ring re-crosses it in the next - // round (the sensor overlap is live), which would make "this round" honestly 1 m — and - // was, measured, the base's red on "Restart cleared the height latches" - await page.evaluate(() => { - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - let n = 0; - for (const c of group.children) { - if (c.userData?.physics?.mode !== 'dynamic') continue; - if (Math.hypot(c.position.x, c.position.z) > 1.6 && c.position.y < 0.9) continue; - c.position.set(-9 + (n++ % 6) * 0.9, 0.35, 8); - c.updateMatrix(); - } - window.__stores.objectsGroup.update((v) => v); - }); - await page.waitForTimeout(1200); - await page.evaluate(() => window.__stores.gameState.setGameState('over', { outcome: "Time's up!" })); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'over', 'the round ends on the Round-over screen', 6000); - await h.eventually(async () => await hud(), (t) => new RegExp('This round: ' + kept.last + ' m').test(t) && new RegExp('Best ever: ' + kept.best + ' m').test(t), `Round over reads this round (${kept.last} m) and the best (${kept.best} m) from storage`, 6000); - // a crate jittering in a ring sensor after the round ended re-fires the crossing while - // every latch reads 0 — the round's stored height must survive it (a `set` would wipe it) - await page.evaluate(() => { - let group; window.__stores.objectsGroup.subscribe((v) => (group = v))(); - const ring = group.getObjectByName('Height ring 1m'); - const crate = group.getObjectByName('Cube 1'); - window.__stores.flowRuntime.fireObjectEnter(ring.uuid, crate.uuid); - }); - await page.waitForTimeout(800); - const afterJitter = await stored(); - h.check(afterJitter.last === kept.last && new RegExp('This round: ' + kept.last + ' m').test(await hud()), `a crossing re-fired on the Round-over screen leaves this round's height alone (${JSON.stringify(afterJitter)})`); - await clickBtn('Play again'); - await h.eventually(() => snap().then((v) => v.state), (v) => v === 'playing', 'Play again starts a fresh round', 8000); - await h.eventually(stored, (v) => v.last === 0 && v.best === kept.best, 'the fresh round zeroes this round\'s height and keeps the best', 6000); - - // 6 — PAUSE menu (P) offers Restart while playing - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); - window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); - }); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'pause', 'P opens the pause menu', 6000); - h.check(/PAUSED/.test(await hud()) && /Restart round/.test(await hud()), 'pause shows a Restart button'); - - // 7 — Restart resets the round: height latches clear, back to the HUD - await clickBtn('Restart round'); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'hud', 'Restart returns to play', 8000); - await h.eventually(async () => await hud(), (t) => /Best height: 0 m/.test(t), 'Restart cleared the height latches', 8000); - - // 8 — 30: Quit to menu, and the menu carries the saved best height - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); - window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + // no editor helper in Play: the markers are scene objects, the templates are out of sight + const helpers = await page.evaluate(() => { + let sc; window.__stores.globalScene.subscribe((v) => (sc = v))(); + const seen = []; + sc.traverse((o) => { if (o.visible && /helper|grid/i.test(o.name ?? '') && o.parent === sc) seen.push(o.name); }); + return seen; }); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'pause', 'P opens the pause menu again', 6000); - await clickBtn('Quit to menu'); - await h.eventually(() => snap().then((v) => v.screen), (v) => v === 'menu', 'Quit returns to the menu', 8000); - await h.eventually(async () => await hud(), (t) => new RegExp('Your best tower: ' + kept.best + ' m').test(t), `the menu shows the saved best (${kept.best} m)`, 6000); + h.check(!helpers.includes('editor-grid'), `no editor grid in Play (${helpers.join(', ') || 'none'})`); await page.evaluate(() => window.__stores.isLocked.set(false)); await page.waitForTimeout(400); diff --git a/tests/e2e/play-reach-jump.test.cjs b/tests/e2e/play-reach-jump.test.cjs new file mode 100644 index 00000000..a034539e --- /dev/null +++ b/tests/e2e/play-reach-jump.test.cjs @@ -0,0 +1,292 @@ +// 31-towers P1 — GRAB REACH and JUMP, the two rules that make a stacking game a puzzle (the +// user's Quest note: "you can't take object from specific distance and need to jump on other +// objects"). Both are CORE and opt-in, so any game can use them: +// · `play.reach` (metres): Interact/Play grabs only what lies within that distance of the +// player's BODY (the segment feet..eye — playReach.js). Desktop Play's crosshair says +// "Too far" and a press does nothing; a carry is held inside the reach. A VR grip on a +// piece out of reach does nothing but a `fail` buzz. Absent = no limit (every older scene). +// · the Character Controller node's `jumpHeight` now jumps in VR as well (right A, Interact +// walking); desktop walk mode already jumped on Space. The capsule stands on a piece. +// Every guard here has its counterfactual measured in the same run (reach removed → the far +// crate is grabbable; jumpHeight 0 → the crate stays a wall). +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const REACH = 1.3; + +const camWorld = (page) => + page.evaluate(() => { + let cam; + window.__stores.playerCam.subscribe((c) => (cam = c))(); + const w = cam.getWorldPosition(new window.__stores.THREE.Vector3()); + return { x: w.x, y: w.y, z: w.z }; + }); +/** put the play eye at a world point, looking at another */ +const aimRig = (page, eye, at) => + page.evaluate( + ({ eye, at }) => { + const THREE = window.__stores.THREE; + let cam; + window.__stores.playerCam.subscribe((c) => (cam = c))(); + const v = new THREE.Vector3(...eye); + if (cam.parent) cam.parent.worldToLocal(v); + cam.position.copy(v); + cam.updateMatrixWorld(true); + if (at) cam.lookAt(new THREE.Vector3(...at)); + else cam.rotation.set(0, 0, 0); + cam.updateMatrixWorld(true); + }, + { eye, at } + ); +const interact = (page) => + page.evaluate(() => { + let s; + window.__stores.playInteract.playInteractState.subscribe((v) => (s = v))(); + return { ...window.__stores.playInteract.playInteractDebug(), ...s }; + }); +const setReach = (page, reach) => + page.evaluate((reach) => window.__stores.scenePhysics.setScenePhysics({ play: { reach } }), reach); +const setGraph = (page, nodes) => + page.evaluate((nodes) => { + window.__stores.setActiveGraph(window.__stores.SCENE_GRAPH); + window.__stores.flowNodes.set( + nodes.map((n) => ({ id: n.id, type: n.type, position: { x: 0, y: 0 }, data: { type: n.type, ...n.data }, class: 'w-[150px]' })) + ); + window.__stores.flowEdges.set([]); + }, nodes); +const CONTROLLER = (jumpHeight) => ({ + id: 'cc', + type: 'charcontroller', + data: { mode: 'walk', speed: 0.05, jumpHeight, eyeHeight: 1.7, gravity: true } +}); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(4000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A'); + const page = A.page; + + // ---- the fixture: a floor (top 0), a NEAR crate, a FAR crate, a crate to jump on -------- + const ids = await page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 4; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 700)); + const [floor, near, far, step] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + const place = (o, pos, scale) => { o.position.set(...pos); o.rotation.set(0, 0, 0); o.scale.set(...scale); o.updateMatrixWorld(true); }; + place(floor, [0, -0.25, 0], [30, 0.5, 30]); + place(near, [0, 0.3, -0.9], [0.6, 0.6, 0.6]); + place(far, [0, 0.3, -2.8], [0.6, 0.6, 0.6]); + place(step, [5, 0.3, 0], [0.8, 0.6, 0.8]); + s.physics.setPhysicsFor(floor.uuid, { mode: 'static' }); + for (const o of [near, far]) s.physics.setPhysicsFor(o.uuid, { mode: 'dynamic', mass: 1 }); + s.physics.setPhysicsFor(step.uuid, { mode: 'dynamic', mass: 8, friction: 1 }); + s.objectActions.deselectObject(); + s.scenePhysics.setScenePhysics({ play: { interaction: 'grab', reach: 1.3 } }); + return { floor: floor.uuid, near: near.uuid, far: far.uuid, step: step.uuid }; + }); + const play = await page.evaluate(() => { + let v; + window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); + return v; + }); + h.check(play.reach === REACH, `the play block stores the reach (${play.reach})`); + await setReach(page, 999); + const clamped = await page.evaluate(() => { let v; window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); return v.reach; }); + h.check(clamped === 20, `an absurd reach clamps at the boundary (${clamped})`); + await setReach(page, REACH); + + await page.evaluate(() => window.__stores.physics.toggleSimulation()); + await h.eventually( + () => page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), + (ok) => ok, + 'premise: a simulation is running (only dynamic bodies of a running sim are grabbable)', + 15000 + ); + await page.evaluate(() => window.__stores.isLocked.set(true)); + await page.waitForTimeout(800); + + // ---- 1. desktop Play: the crosshair knows what is within reach of your BODY ------------------ + await aimRig(page, [0, 1.7, 0], [0, 0.35, -0.9]); + await h.eventually(() => interact(page), (s) => s.mode === 'aiming' && s.uuid === ids.near, 'a crate at your feet (0.9 m away, 1.7 m below the eye) is IN reach — it is measured from the body, not the eye', 4000); + await aimRig(page, [0, 1.7, 0], [0, 0.35, -2.8]); + await h.eventually(() => interact(page), (s) => s.mode === 'toofar' && s.uuid === ids.far, 'a crate 2.8 m away reads TOO FAR', 4000); + h.check(/Too far/.test((await page.locator('#play-reticle-toofar').textContent().catch(() => '')) ?? ''), 'the crosshair says "Too far — get closer"'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true }))); + await page.waitForTimeout(250); + let d = await interact(page); + h.check(d.carrying === null && d.lastUp === 'too-far' && d.reachRefusals >= 1, `a press on it carries nothing (carrying ${d.carrying}, ${d.lastUp}, refusals ${d.reachRefusals})`); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true }))); + // counterfactual: the SAME aim with the reach removed is an ordinary grab + await setReach(page, null); + await h.eventually(() => interact(page), (s) => s.mode === 'aiming' && s.uuid === ids.far, 'COUNTERFACTUAL: with no reach the far crate is grabbable (the refusal is the reach, nothing else)', 4000); + await setReach(page, REACH); + await h.eventually(() => interact(page), (s) => s.mode === 'toofar', 'the reach restored, it is too far again', 4000); + + // a carry is held INSIDE the reach, however far the wheel pushes it + await aimRig(page, [0, 1.7, 0], [0, 0.35, -0.9]); + await h.eventually(() => interact(page), (s) => s.mode === 'aiming' && s.uuid === ids.near, 'premise: aiming at the near crate again', 4000); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true }))); + await page.waitForTimeout(200); + d = await interact(page); + h.check(d.carrying === ids.near, `a press on the near crate carries it (${d.carrying === ids.near})`); + await aimRig(page, [0, 1.7, 0], null); // look level ahead + for (let i = 0; i < 24; i++) await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }))); + await page.waitForTimeout(1500); + const held = await page.evaluate((uuid) => { + let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + return { x: o.position.x, y: o.position.y, z: o.position.z }; + }, ids.near); + d = await interact(page); + const heldDist = Math.hypot(held.x, held.z); + h.check(d.distance > 4, `premise: the wheel asked for a long carry (${d.distance.toFixed(2)} m)`); + h.check(heldDist < REACH + 0.2, `the carried crate stays within reach (${heldDist.toFixed(2)} m from the body, reach ${REACH})`); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true }))); + await page.waitForTimeout(300); + + // ---- 2. desktop jump: a Character Controller in walk mode stands ON a piece --------------- + await setGraph(page, [CONTROLLER(1.0)]); + await h.eventually(() => page.evaluate(() => window.__stores.charController.charControllerDebug().control?.mode), (m) => m === 'walk', 'premise: the walk controller is declared', 5000); + await aimRig(page, [5, 1.7, 1.5], null); // 1.5 m south of the step crate, looking -Z at it + await h.eventually(() => page.evaluate(() => window.__stores.charController.charControllerDebug().walker), (w) => w.source === 'rapier' && w.grounded, 'premise: the walker stands on the floor through the rapier capsule', 8000); + await page.keyboard.down('KeyW'); + await page.waitForTimeout(1200); + await page.keyboard.up('KeyW'); + await page.waitForTimeout(300); + let eye = await camWorld(page); + h.check(Math.abs(eye.y - 1.7) < 0.08 && eye.z > 0.2, `walking into the 0.6 m crate stops at it — too tall to step up (eye ${eye.y.toFixed(2)}, z ${eye.z.toFixed(2)})`); + // W + Space, and let go of W once over the crate (walking has no momentum: a held W + // carries you clean over a 0.8 m crate at 3 m/s — measured, the arc landed at z -1.8) + await page.keyboard.down('KeyW'); + await page.keyboard.down('Space'); + await page.waitForTimeout(320); + await page.keyboard.up('KeyW'); + await page.keyboard.up('Space'); + await page.waitForTimeout(1200); + eye = await camWorld(page); + const walker = (await page.evaluate(() => window.__stores.charController.charControllerDebug())).walker; + h.check(Math.abs(eye.y - 2.3) < 0.12 && walker.grounded, `JUMP: Space + W lands you ON the crate (eye ${eye.y.toFixed(2)} = 0.6 + 1.7, grounded ${walker.grounded})`); + const stepNow = await page.evaluate((uuid) => { + let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + return [o.position.x, o.position.z]; + }, ids.step); + h.check(Math.hypot(stepNow[0] - 5, stepNow[1]) < 0.15, `the crate you stand on was not shoved (${stepNow.map((n) => n.toFixed(2))})`); + // counterfactual: no jump height, and the crate is a wall again + await setGraph(page, [CONTROLLER(0)]); + await page.waitForTimeout(300); + await aimRig(page, [5, 1.7, 1.5], null); + await page.waitForTimeout(800); + await page.keyboard.down('KeyW'); + await page.keyboard.down('Space'); + await page.waitForTimeout(150); + await page.keyboard.up('Space'); + await page.waitForTimeout(1200); + await page.keyboard.up('KeyW'); + await page.waitForTimeout(600); + eye = await camWorld(page); + h.check(Math.abs(eye.y - 1.7) < 0.08, `COUNTERFACTUAL: with jumpHeight 0 the same keys leave you on the floor (eye ${eye.y.toFixed(2)})`); + await page.evaluate(() => window.__stores.isLocked.set(null)); + await page.waitForTimeout(500); + + // ---- 3. VR Interact: a grip takes only what is within reach of the body --------------------- + await setGraph(page, [CONTROLLER(1.0)]); + await page.evaluate(() => window.__stores.isVRMode.set(true)); + await xr.install(page); + await xr.installSpace(page, { head: [0, 1.6, 0], yaw: 0 }); + await page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await page.waitForTimeout(400); + const gripAt = (target) => + page.evaluate( + ({ target }) => { + const s = window.__stores; + const THREE = s.THREE; + let cam; s.globalCamera.subscribe((c) => (cam = c))(); + cam.position.set(0, 1.6, 0); + cam.updateMatrixWorld(true); + const hand = new THREE.Vector3(0.2, 1.2, -0.25); + const ray = new THREE.Raycaster(hand, new THREE.Vector3(...target).sub(hand).normalize()); + const got = s.vrControls.gripTargetOf(ray, hand, 'interact'); + return { got: got?.uuid ?? null, refusal: s.vrControls.lastGripRefusalDebug() }; + }, + { target } + ); + let g = await gripAt([0, 0.3, -0.9]); + h.check(g.got === ids.near && g.refusal === null, `VR: a grip on the near crate takes it (${g.got === ids.near})`); + g = await gripAt([0, 0.3, -2.8]); + h.check(g.got === null && g.refusal?.uuid === ids.far, `VR: a grip on the far crate takes nothing, and says why (${JSON.stringify(g.refusal)})`); + await setReach(page, null); + g = await gripAt([0, 0.3, -2.8]); + h.check(g.got === ids.far, 'COUNTERFACTUAL: with no reach the same grip takes the far crate'); + await setReach(page, REACH); + // the real per-frame path: squeezing at the far crate buzzes `fail` and holds nothing + await xr.pose(page, 'right', [0.2, 1.2, -0.25], { pitch: -Math.atan2(0.9, 2.55), yaw: 0 }); + const before = await xr.pulses(page); + await xr.button(page, 'right', 1, true); + await page.waitForTimeout(400); + const grip = await page.evaluate(() => window.__stores.vrControls.vrGripDebug()); + const after = await xr.pulses(page); + await xr.button(page, 'right', 1, false); + await page.waitForTimeout(200); + h.check(grip.grab === null && !grip.worldGrab, `VR: the squeeze holds nothing and never grabs the world (${JSON.stringify(grip)})`); + h.check(after.right > before.right, `VR: ...and buzzes the hand (pulses ${before.right} -> ${after.right})`); + + // ---- 4. VR jump: right A leaves the ground while the controller can jump --------------------- + await xr.installSpace(page, { head: [-6, 1.6, 6], yaw: 0 }); + await page.waitForTimeout(600); + h.check((await page.evaluate(() => window.__stores.vrControls.vrJumpHeight())) === 1, 'VR: Interact + a walk controller with jumpHeight 1 can jump'); + const sampleHead = async (ms) => { + let max = -Infinity; + const t0 = Date.now(); + while (Date.now() - t0 < ms) { + const hd = await xr.head(page); + if (hd.y > max) max = hd.y; + await page.waitForTimeout(40); + } + return max; + }; + await xr.button(page, 'right', 4, true); + const peak = await sampleHead(700); + await xr.button(page, 'right', 4, false); + await page.waitForTimeout(1200); + let hd = await xr.head(page); + h.check(peak > 2.2, `VR: pressing A lifts the head well off the ground (peak ${peak.toFixed(2)}, standing 1.60)`); + h.check(Math.abs(hd.y - 1.6) < 0.1, `VR: ...and it lands again (${hd.y.toFixed(2)})`); + const ptt = await page.evaluate(() => { let v; window.__stores.voiceChat.pttActive.subscribe((x) => (v = x))(); return v; }); + h.check(!ptt, `VR: while A jumps it is not push-to-talk (${ptt})`); + // counterfactual: no controller node, no jump + await setGraph(page, []); + await h.eventually(() => page.evaluate(() => window.__stores.vrControls.vrJumpHeight()), (v) => v === 0, 'with no controller node the VR jump is off', 4000); + await xr.button(page, 'right', 4, true); + const flat = await sampleHead(600); + await xr.button(page, 'right', 4, false); + h.check(flat < 1.7, `COUNTERFACTUAL: A no longer jumps (peak ${flat.toFixed(2)})`); + await xr.uninstall(page); + await page.evaluate(() => window.__stores.isVRMode.set(false)); + await page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + + // ---- 5. Configure Scene ▸ Physics ▸ Play mode: the Limit grab reach row ---------------------- + await page.evaluate(() => window.__stores.openSceneSection('Physics')); + await page.waitForTimeout(1200); + const row = await page.evaluate(() => ({ + on: document.querySelector('#physics-play-reach-on')?.checked ?? null, + slider: !!document.querySelector('#physics-play-reach') + })); + h.check(row.on === true && row.slider, `the Inspector row reads the scene's reach (${JSON.stringify(row)})`); + await page.click('#physics-play-reach-on'); + await page.waitForTimeout(400); + const off = await page.evaluate(() => { let v; window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); return { has: 'reach' in v, slider: !!document.querySelector('#physics-play-reach') }; }); + h.check(!off.has && !off.slider, `unticking removes the key (a scene without a reach saves as before) and folds the slider (${JSON.stringify(off)})`); + await page.click('#physics-play-reach-on'); + await page.waitForTimeout(400); + const on = await page.evaluate(() => { let v; window.__stores.scenePhysics.scenePlay.subscribe((x) => (v = x))(); return v.reach; }); + h.check(on === 1.3, `ticking it sets a 1.3 m reach (${on})`); + + await h.finish(browser); +}); diff --git a/tests/e2e/towers-levels.test.cjs b/tests/e2e/towers-levels.test.cjs new file mode 100644 index 00000000..2cddd372 --- /dev/null +++ b/tests/e2e/towers-levels.test.cjs @@ -0,0 +1,304 @@ +// 31-towers — THE TOWERS LEVELS, played through the real scene (T1 + T2 of the Quest feedback: +// "after finishing basic level nothing happens, just like hooray" / "you can't take object from +// specific distance and need to jump on other objects"). +// +// Loaded from the AUTHORED .tpscene (TOWERS_TPSCENE=, else the sibling scenes checkout) +// and driven through the real surfaces: the HUD buttons are clicked, the core `towers` module +// deals pieces, judges the round and saves stars through api.storage. A tower is "built" by +// placing the dealt pieces the way a player's carry ends (a pose write the simulation takes as +// an external move) — the grab itself is play-reach-jump's job. +// +// Skip-never-fail when the scene is absent or pre-31 (no `Towers game` marker). +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_REPO = [path.resolve(__dirname, '../../../theprototype.app-scenes'), path.resolve(__dirname, '../../../scenes')].find((p) => + fs.existsSync(p) +); +const TPSCENE = process.env.TOWERS_TPSCENE || (SCENES_REPO && path.join(SCENES_REPO, 'games/towers/scene.tpscene')); + +h.run(async () => { + if (!TPSCENE || !fs.existsSync(TPSCENE)) { + console.log('SKIP: no games/towers/scene.tpscene (set TOWERS_TPSCENE to the staged build)'); + return; + } + const browser = await h.launch({ args: h.GPU_ARGS }); + { + const warm = await h.setupPage(browser, 'warm'); + await warm.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await warm.page.waitForTimeout(3000); + await warm.ctx.close(); + } + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + const bytes = Array.from(fs.readFileSync(TPSCENE)); + const load = async () => { + await page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); + await page.waitForTimeout(1500); + }; + await load(); + const marker = await page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return !!g.getObjectByName('Towers game'); + }); + if (!marker) { + console.log('SKIP: this Towers scene predates 31 (no "Towers game" marker) — author it or set TOWERS_TPSCENE'); + await h.finish(browser); + return; + } + h.check(await page.evaluate(() => !!window.__towers), 'the core towers module is loaded'); + await page.evaluate(() => window.__towers.resetProgress()); + + const hud = async () => (await page.locator('#hud-layer').textContent()) ?? ''; + const snap = () => + page.evaluate(() => { + const s = window.__stores; + let gs; + s.gameState.gameState.subscribe((v) => (gs = v))(); + return { state: gs.state, round: gs.round, vars: window.__towers.vars(), pieces: window.__towers.pieces(), screen: s.hudDocs.visibleScreen('scene')?.id ?? null }; + }); + const clickBtn = (text) => page.locator('#hud-layer button', { hasText: text }).first().click(); + const lastAnnounce = () => page.evaluate(() => { + const d = window.__stores.gameKit?.gameFeelActions?.gameFeelActionsDebug?.(); + return (d?.last ?? []).filter((e) => e.type === 'announce').map((e) => e.text); + }); + const bannerText = async () => (await page.locator('#game-announce').textContent().catch(() => '')) ?? ''; + /** place a dealt piece the way a carry ends: a pose write the simulation adopts */ + const placePiece = (uuid, pos, yaw = 0) => + page.evaluate( + ({ uuid, pos, yaw }) => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + const o = g.getObjectByProperty('uuid', uuid); + o.position.set(...pos); + o.rotation.set(0, yaw, 0); + o.updateMatrix(); + o.updateMatrixWorld(true); + window.__stores.objectsGroup.update((v) => v); + }, + { uuid, pos, yaw } + ); + const byShape = (st, shape) => st.pieces.filter((p) => p.shape === shape); + + // ---- 1. the level select: level 1 open, the rest locked ------------------------------------ + await page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually(() => page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), (ok) => ok, 'Play starts the simulation', 12000); + await h.eventually(() => snap(), (s) => s.state === 'menu' && s.screen === 'menu', 'the game opens on the level select', 6000); + let text = await hud(); + h.check(/1 · Stack/.test(text) && /12 · Summit/.test(text), 'the level select lists all twelve levels (1 · Stack … 12 · Summit)'); + await h.eventually(() => page.evaluate(() => [1, 2, 12].map((i) => window.__towers.info({ read: 'levelStars', level: i }))), (v) => v[0] === '☆☆☆' && /locked/.test(v[1]) && /locked/.test(v[2]), 'fresh progress: level 1 open (☆☆☆), levels 2 and 12 locked', 4000); + h.check(/Stars earned: 0 \/ 36/.test(text), `the menu counts the stars (${(text.match(/Stars earned[^A-Z]*/) ?? [''])[0]})`); + // a LOCKED level refuses, and says why + await clickBtn('2 · Planks'); + await page.waitForTimeout(800); + let st = await snap(); + h.check(st.state === 'menu' && st.pieces.length === 0, `pressing locked level 2 starts nothing (${st.state}, ${st.pieces.length} pieces)`); + h.check(/Level 2 is locked/.test(await bannerText()), `...and the banner says it is locked (${await bannerText()})`); + + // ---- 2. level 1: the pieces are dealt, the HUD reads the level ---------------------------- + await clickBtn('1 · Stack'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 1, 'level 1 starts', 8000); + await h.eventually(() => snap(), (s) => byShape(s, 'cube').length === 6, 'six cubes are dealt onto the racks', 8000); + st = await snap(); + h.check(st.pieces.every((p) => Math.abs(p.pos[0]) > 3.3 && p.pos[1] > 0.8), `every cube sits on a rack (${st.pieces.map((p) => p.pos.map((n) => n.toFixed(1)).join(',')).join(' | ')})`); + await h.eventually(() => bannerText(), (t) => /Level 1 · Stack/.test(t), 'the intro banner names the level and its goal', 5000); + await h.eventually(hud, (t) => /Level 1 · Stack/.test(t) && /Goal 1\.8 m/.test(t) && /Rack 6/.test(t) && /\d:\d\d/.test(t), 'the HUD shows the level, the goal, the supply and the clock', 5000); + const holdText = await page.evaluate(() => { + const el = [...document.querySelectorAll('#hud-layer .hud-text')].find((e) => e.closest('[data-el-id="tw-hold"]') || e.id === 'tw-hold'); + return el ? el.textContent.trim() : [...document.querySelectorAll('#hud-layer .hud-text')].map((e) => e.textContent.trim()); + }); + h.check(!/^0$/.test(String(holdText)) && !(Array.isArray(holdText) && holdText.includes('0')), `no stray "0" where the hold countdown waits (${JSON.stringify(holdText)})`); + const goal = await page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return g.getObjectByName('Goal ring').position.toArray(); + }); + h.check(Math.abs(goal[1] - 1.8) < 0.01 && Math.hypot(goal[0], goal[2]) < 0.01, `the gold ring moved to the goal height over the pad (${goal.map((n) => n.toFixed(2))})`); + + // ---- 3. a scripted WIN: three cubes on the pad, held for three seconds ------------------------- + const cubes = byShape(st, 'cube'); + for (let i = 0; i < 3; i++) { + await placePiece(cubes[i].uuid, [0, 0.52 + i * 0.61, 0]); + await page.waitForTimeout(500); + } + await h.eventually(hud, (t) => /Hold steady/.test(t), 'a tower at the goal starts the hold countdown', 5000); + await h.eventually(() => snap(), (s) => s.state === 'over' && s.vars.twStatus === 2, 'held for three seconds, the level is WON', 9000); + st = await snap(); + h.check(st.vars.twUsed === 3 && st.vars.twStars >= 2, `the result counts the tower: ${st.vars.twUsed} pieces, ${st.vars.twStars} stars`); + await h.eventually(hud, (t) => /Level complete!/.test(t) && /★/.test(t) && /Next level/.test(t) && /Retry/.test(t), 'the results panel: Level complete!, the stars, Next / Retry / Levels', 5000); + const feel = await page.evaluate(() => window.__stores.gameKit?.gameFeelActions?.gameFeelActionsDebug?.() ?? null); + h.check(!feel || true, 'premise: game-feel debug readable'); + await h.eventually( + () => page.evaluate(() => { + const raw = localStorage.getItem('tp:mod:towers:progress'); + return raw ? JSON.parse(raw) : null; + }), + (p) => p?.levels?.['1']?.stars >= 2, + 'the stars are saved on this device (tp:mod:towers:progress)', + 4000 + ); + h.check(await page.evaluate(() => window.__towers.info({ read: 'levelStars', level: 2 })) === '☆☆☆', 'a star on level 1 opens level 2'); + + // ---- 4. the unlock PERSISTS across a reload --------------------------------------------------- + await page.reload(); + await page.waitForFunction(() => window.__stores && !!window.__stores.sessions && !!window.__towers, null, { timeout: 40000 }); + await load(); + await page.evaluate(() => window.__stores.isLocked.set(true)); + await h.eventually(() => page.evaluate(() => window.__stores.physics.physicsWorldDebug().running), (ok) => ok, 'after a reload, Play starts the simulation again', 12000); + await h.eventually(() => page.evaluate(() => [1, 2, 3].map((i) => window.__towers.info({ read: 'levelStars', level: i }))), (v) => /★/.test(v[0]) && v[1] === '☆☆☆' && /locked/.test(v[2]), 'after a reload: level 1 keeps its stars, level 2 is open, level 3 still locked', 6000); + + // ---- 5. level 2 (Planks): Retry and Next through the results, and a second scripted win --------- + await clickBtn('2 · Planks'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 2 && s.pieces.length === 8, 'level 2 deals its eight pieces (planks, beams, cubes)', 9000); + st = await snap(); + h.check(byShape(st, 'plank').length === 4 && byShape(st, 'beam').length === 2 && byShape(st, 'cube').length === 2, `the Planks supply: ${JSON.stringify(Object.fromEntries(['plank', 'beam', 'cube'].map((k) => [k, byShape(st, k).length])))}`); + const planks = byShape(st, 'plank'); + const l2cubes = byShape(st, 'cube'); + let y = 0.2; + for (const p of planks) { + await placePiece(p.uuid, [0, y + 0.16, 0]); + y += 0.31; + await page.waitForTimeout(450); + } + for (const c of l2cubes) { + await placePiece(c.uuid, [0, y + 0.31, 0]); + y += 0.61; + await page.waitForTimeout(450); + } + await h.eventually(() => snap(), (s) => s.state === 'over' && s.vars.twStatus === 2 && s.vars.twLevel === 2, 'level 2 is WON with a plank-and-cube tower', 12000); + await h.eventually(() => page.evaluate(() => JSON.parse(localStorage.getItem('tp:mod:towers:progress') ?? '{}')), (p) => p?.levels?.['2']?.stars >= 1, 'level 2 stars saved', 4000); + h.check(await page.evaluate(() => window.__towers.info({ read: 'levelStars', level: 3 })) === '☆☆☆', 'winning level 2 opens level 3'); + // Next → level 3 + await clickBtn('Next level'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 3, 'Next level starts level 3 (Climb)', 8000); + await h.eventually(() => snap(), (s) => s.pieces.length === 8, 'level 3 deals 8 cubes', 8000); + st = await snap(); + const high = st.pieces.filter((p) => p.pos[1] > 2.9); + h.check(high.length === 3, `three of them wait on the HIGH ledge (${high.length})`); + // from the floor under the ledge, a ledge cube is out of reach + await page.evaluate((target) => { + const s = window.__stores; + let cam; + s.playerCam.subscribe((c) => (cam = c))(); + // from the pad's north edge: the ledge itself would block the view from right under it + const v = new s.THREE.Vector3(target[0], 1.7, -0.9); + if (cam.parent) cam.parent.worldToLocal(v); + cam.position.copy(v); + cam.updateMatrixWorld(true); + cam.lookAt(new s.THREE.Vector3(target[0], target[1], target[2])); + cam.updateMatrixWorld(true); + }, high[0].pos); + await h.eventually(() => page.evaluate(() => { let v; window.__stores.playInteract.playInteractState.subscribe((x) => (v = x))(); return v.mode; }), (m) => m === 'toofar', 'a ledge cube is TOO FAR from the floor — the level needs steps or a jump', 4000); + + // ---- 6. a LOSS: pieces dropped outside the yard are lost, and too many ends the level ----------- + const floorCubes = (await snap()).pieces.filter((p) => p.pos[1] < 2.5).slice(0, 3); + for (let i = 0; i < 3; i++) { + await placePiece(floorCubes[i].uuid, [-8 + i * 1.2, 0.31, 8]); + await page.waitForTimeout(200); + } + await h.eventually(() => snap(), (s) => s.vars.twLost >= 1, 'a cube resting outside the yard is LOST (it poofs)', 6000); + await h.eventually(() => snap(), (s) => s.state === 'over' && s.vars.twStatus === 5, 'losing more than the level allows ends it: Out of pieces', 8000); + await h.eventually(hud, (t) => /Out of pieces/.test(t) && /Retry/.test(t), 'the results say why and offer Retry', 4000); + await clickBtn('Retry'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 3 && s.vars.twLost === 0 && s.pieces.length === 8, 'Retry deals level 3 afresh', 9000); + + // ---- 7. P pause: Restart level and Levels --------------------------------------------------------- + const round = (await snap()).round; + await page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + }); + await h.eventually(() => snap(), (s) => s.screen === 'pause', 'P opens the pause menu', 5000); + await clickBtn('Restart level'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.round > round && s.screen === 'hud', 'Restart level starts a fresh round of the same level', 8000); + await page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + }); + await h.eventually(() => snap(), (s) => s.screen === 'pause', 'P again', 5000); + await clickBtn('Levels'); + await h.eventually(() => snap(), (s) => s.state === 'menu' && s.screen === 'menu', 'Levels returns to the level select', 8000); + + // ---- 8. the P3 rules: open everything, then wind, the outline and the delivery -------------------- + await page.evaluate(() => { + const levels = {}; + for (let i = 1; i <= 12; i++) levels[i] = { stars: 1, time: 99, pieces: 9 }; + window.__towers.setProgress({ levels }); + }); + h.check((await snap()).pieces.length === 0, 'the level select stands in an EMPTY arena (the last level\'s pieces went)'); + // GUSTS (level 8): a piece standing high on the pad is pushed when the gust arrives + await clickBtn('8 · Gusts'); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 8 && s.pieces.length === 9, 'level 8 deals its pieces', 9000); + st = await snap(); + const tall = byShape(st, 'cube').slice(0, 3); + for (let i = 0; i < 3; i++) { + await placePiece(tall[i].uuid, [0, 0.52 + i * 0.61, 0]); + await page.waitForTimeout(400); + } + await h.eventually(() => bannerText(), (t) => /Gust from the/.test(t), 'a gust is WARNED before it comes', 12000); + const topCube = tall[2].uuid; + const before = await page.evaluate((uuid) => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return g.getObjectByProperty('uuid', uuid).position.toArray(); }, topCube); + await page.waitForTimeout(2500); + const pushed = await page.evaluate((uuid) => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); const o = g.getObjectByProperty('uuid', uuid); return o ? o.position.toArray() : null; }, topCube); + h.check(!pushed || Math.hypot(pushed[0] - before[0], pushed[2] - before[2]) > 0.05, `the gust PUSHED the top of the tower (${before.map((n) => n.toFixed(2))} -> ${pushed ? pushed.map((n) => n.toFixed(2)) : 'fell and was lost'})`); + // WOBBLE (level 10): the plate rocks only in its own level, as a KINEMATIC body + const plateTilt = () => page.evaluate(() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); const o = g.getObjectByName('Wobble plate'); return Math.abs(o.rotation.x) + Math.abs(o.rotation.z); }); + h.check((await plateTilt()) < 0.005, 'outside level 10 the wobble plate stands still'); + await page.evaluate(() => window.__towers.startLevel(10)); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 10 && s.pieces.length === 7, 'level 10 (Wobble) deals a heavy base, cubes and planks', 9000); + const tilts = []; + for (let i = 0; i < 6; i++) { + tilts.push(await plateTilt()); + await page.waitForTimeout(300); + } + h.check(Math.max(...tilts) > 0.02, `in level 10 the plate ROCKS (tilts ${tilts.map((t) => t.toFixed(3)).join(' ')})`); + const plateBody = await page.evaluate(() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); const uuid = g.getObjectByName('Wobble plate').uuid; return window.__stores.physics.physicsDebug().find((b) => b.uuid === uuid)?.mode ?? null; }); + h.check(plateBody === 'kinematic', `...as a kinematic body, so it carries what stands on it (${plateBody})`); + // OUTLINE (level 11): six cubes into the ghost wall's six cells + await page.evaluate(() => window.__towers.startLevel(11)); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 11 && byShape(s, 'cube').length === 6, 'level 11 (Outline) deals six cubes', 9000); + const ghost = await page.evaluate(() => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return g.getObjectByName('Ghost wall').position.toArray(); }); + h.check(Math.abs(ghost[1] - 0.8) < 0.05, `the ghost wall stands on the pad (${ghost.map((n) => n.toFixed(2))})`); + st = await snap(); + const cells = [[-0.6, 0.52], [0, 0.52], [0.6, 0.52], [-0.6, 1.13], [0, 1.13], [0.6, 1.13]]; + const oc = byShape(st, 'cube'); + for (let i = 0; i < 6; i++) { + await placePiece(oc[i].uuid, [cells[i][0], cells[i][1], 0]); + await page.waitForTimeout(350); + } + await h.eventually(() => snap(), (s) => s.vars.twFilled === 6, 'every cell of the ghost wall counts as filled', 6000); + await h.eventually(() => snap(), (s) => s.state === 'over' && s.vars.twStatus === 2 && s.vars.twLevel === 11, 'the filled outline, held, WINS level 11', 9000); + // DELIVER (level 12): the star on the perch; set on a tower above 3.6 m it wins + await page.evaluate(() => window.__towers.startLevel(12)); + await h.eventually(() => snap(), (s) => s.state === 'playing' && s.vars.twLevel === 12 && byShape(s, 'star').length === 1, 'level 12 (Summit) deals the star onto the perch', 9000); + st = await snap(); + const star = byShape(st, 'star')[0]; + h.check(star.pos[1] > 3.4, `the star waits on the high perch (y ${star.pos[1].toFixed(2)})`); + const base = byShape(st, 'base')[0]; + await placePiece(base.uuid, [0, 0.38, 0]); + await page.waitForTimeout(500); + const stack = [...byShape(st, 'cube'), ...byShape(st, 'plank')]; + y = 0.55; + for (const p of stack.slice(0, 5)) { + const hgt = p.shape === 'cube' ? 0.6 : 0.3; + await placePiece(p.uuid, [0, y + hgt / 2 + 0.01, 0]); + y += hgt + 0.01; + await page.waitForTimeout(450); + } + // the arch on top (a building block: its origin is on its floor) + await placePiece(byShape(st, 'arch')[0].uuid, [0, y + 0.01, 0]); + y += 0.81; + await page.waitForTimeout(500); + await placePiece(star.uuid, [0, y + 0.3, 0]); + await h.eventually(() => snap(), (s) => s.state === 'over' && s.vars.twLevel === 12 && s.vars.twStatus === 2, `the star set on top of a ${y.toFixed(2)} m tower WINS the summit`, 12000); + + await page.evaluate(() => window.__stores.isLocked.set(false)); + await page.waitForTimeout(400); + await h.finish(browser); +}); diff --git a/tests/unit/playReach.test.js b/tests/unit/playReach.test.js new file mode 100644 index 00000000..71d11342 --- /dev/null +++ b/tests/unit/playReach.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeReach, bodyDistance, withinReach, carryLimit, REACH_MIN, REACH_MAX } from '../../src/lib/playReach.js'; + +// 31-towers P1: grab reach is measured from the BODY (feet..eye), not the eye. +const eye = { x: 0, y: 1.7, z: 0 }; +const feet = 0; + +describe('normalizeReach', () => { + it('absent, empty and nonsense mean "no limit"', () => { + for (const v of [undefined, null, '', 'far', NaN, 0, -1, true, false]) expect(normalizeReach(v)).toBe(null); + }); + it('a number clamps into range', () => { + expect(normalizeReach(1.3)).toBe(1.3); + expect(normalizeReach('2')).toBe(2); + expect(normalizeReach(0.05)).toBe(REACH_MIN); + expect(normalizeReach(999)).toBe(REACH_MAX); + }); +}); + +describe('bodyDistance', () => { + it('a crate at your feet is as far as it is horizontally (the eye would say 1.7 m)', () => { + expect(bodyDistance({ x: 0.8, y: 0.3, z: 0 }, eye, feet)).toBeCloseTo(0.8, 6); + }); + it('between the feet and the eye it is the horizontal distance', () => { + expect(bodyDistance({ x: 0, y: 1.2, z: -1.1 }, eye, feet)).toBeCloseTo(1.1, 6); + }); + it('above the eye it bends round the head', () => { + expect(bodyDistance({ x: 0.3, y: 2.9, z: 0 }, eye, feet)).toBeCloseTo(Math.hypot(0.3, 1.2), 6); + }); + it('below the feet it is measured from the feet', () => { + expect(bodyDistance({ x: 0, y: -0.5, z: 0.4 }, eye, feet)).toBeCloseTo(Math.hypot(0.4, 0.5), 6); + }); +}); + +describe('withinReach', () => { + it('no reach = always', () => { + expect(withinReach({ x: 40, y: 9, z: 0 }, eye, feet, null)).toBe(true); + }); + it('a piece on a 3 m ledge is out of a 1.3 m reach from the floor, in reach after a 1 m jump', () => { + const piece = { x: 0, y: 3.05, z: -0.5 }; + expect(withinReach(piece, eye, feet, 1.3)).toBe(false); + expect(withinReach(piece, { x: 0, y: 2.7, z: 0 }, 1.0, 1.3)).toBe(true); + }); + it('standing on a 0.6 m crate brings it in reach too', () => { + expect(withinReach({ x: 0, y: 3.05, z: -0.5 }, { x: 0, y: 2.3, z: 0 }, 0.6, 1.3)).toBe(true); + }); +}); + +describe('carryLimit', () => { + const flat = { x: 0, y: 0, z: -1 }; + it('no reach keeps the fallback', () => { + expect(carryLimit(flat, 1.7, null, 4.2)).toBe(4.2); + }); + it('straight ahead the limit is the reach; looking up it is the reach again', () => { + expect(carryLimit(flat, 1.7, 1.3, 9)).toBeCloseTo(1.3, 6); + expect(carryLimit({ x: 0, y: 0.6, z: -0.8 }, 1.7, 1.3, 9)).toBeCloseTo(1.3, 6); + }); + it('looking down the limit grows — the body segment is under you — and always lands ON the boundary', () => { + for (const deg of [10, 30, 50, 70, 85]) { + const a = (deg * Math.PI) / 180; + const dir = { x: 0, y: -Math.sin(a), z: -Math.cos(a) }; + const t = carryLimit(dir, 1.7, 1.3, 99); + expect(t).toBeGreaterThanOrEqual(1.3 - 1e-9); + const p = { x: eye.x + dir.x * t, y: eye.y + dir.y * t, z: eye.z + dir.z * t }; + expect(bodyDistance(p, eye, eye.y - 1.7)).toBeCloseTo(1.3, 5); + } + }); +}); diff --git a/tests/unit/towersLevels.test.js b/tests/unit/towersLevels.test.js new file mode 100644 index 00000000..d26b7d03 --- /dev/null +++ b/tests/unit/towersLevels.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { + LEVELS, SHAPES, ZONES, levelById, nextLevelId, supplyCount, goalHeight, starsFor, starsText, formatTime, + normalizeProgress, mergeResult, isUnlocked, totalStars, layoutSupply, towerTop, towerPieces, isOutside, + outlineCells, cellsFilled, gustAt, gust, windFrom, judge, verdictTitle, STATUS, statusName, HOLD_SECONDS +} from '../../src/modules/towers/levels.js'; + +// 31-towers P2: the Towers levels leaf — data, stars, unlocks, layout, the judge, the wind. + +describe('the level table', () => { + it('twelve levels, ids 1..12, each with a known zone, known shapes, a goal and par', () => { + expect(LEVELS.map((l) => l.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + for (const l of LEVELS) { + expect(ZONES[/** @type {string} */ (l.zone)]).toBeTruthy(); + for (const s of l.supply) expect(SHAPES[/** @type {string} */ (s.shape)]).toBeTruthy(); + expect(['height', 'outline', 'deliver']).toContain(l.goal.type); + expect(l.par.pieces).toBeGreaterThan(0); + expect(l.par.time).toBeLessThan(l.limit); + expect(l.par.pieces).toBeLessThanOrEqual(supplyCount(l)); + expect(typeof l.intro).toBe('string'); + } + }); + it('the shapes progress: cubes, then planks, wedges, barrels, arches, balls, a heavy base', () => { + const firstUse = (/** @type {string} */ shape) => LEVELS.find((l) => l.supply.some((/** @type {any} */ s) => s.shape === shape))?.id; + expect(firstUse('cube')).toBe(1); + expect(firstUse('plank')).toBe(2); + expect(firstUse('wedge')).toBe(4); + expect(firstUse('barrel')).toBe(5); + expect(firstUse('arch')).toBe(6); + expect(firstUse('lblock')).toBe(6); + expect(firstUse('ball')).toBe(9); + expect(firstUse('base')).toBe(10); + expect(firstUse('star')).toBe(12); + }); + it('lookups', () => { + expect(levelById('3')?.name).toBe('Climb'); + expect(levelById(13)).toBe(null); + expect(nextLevelId(11)).toBe(12); + expect(nextLevelId(12)).toBe(null); + expect(goalHeight(levelById(3))).toBe(3.6); + expect(goalHeight(levelById(11))).toBeGreaterThan(1); + }); +}); + +describe('stars and progress', () => { + const l1 = levelById(1); + it('a win is a star, par pieces and par time a star each; a loss is none', () => { + expect(starsFor(l1, { won: false, time: 10, pieces: 1 })).toBe(0); + expect(starsFor(l1, { won: true, time: 999, pieces: 99 })).toBe(1); + expect(starsFor(l1, { won: true, time: 999, pieces: 3 })).toBe(2); + expect(starsFor(l1, { won: true, time: 45, pieces: 3 })).toBe(3); + expect(starsText(2)).toBe('★★☆'); + expect(formatTime(92.2)).toBe('1:33'); + }); + it('the saved progress keeps the best of every number and ignores rubbish', () => { + let p = normalizeProgress({ levels: { 1: { stars: 9, time: 'x' }, 99: { stars: 3 }, 2: { stars: 0 } } }); + expect(p).toEqual({ levels: { 1: { stars: 3, time: 0, pieces: 0 } } }); + p = mergeResult({}, 1, { stars: 1, time: 80, pieces: 5 }); + p = mergeResult(p, 1, { stars: 2, time: 95, pieces: 3 }); + p = mergeResult(p, 1, { stars: 0, time: 10, pieces: 1 }); // a loss changes nothing + expect(p.levels['1']).toEqual({ stars: 2, time: 80, pieces: 3 }); + expect(totalStars(p)).toBe(2); + }); + it('level 1 is open; level k+1 opens with a star on level k', () => { + expect(isUnlocked({}, 1)).toBe(true); + expect(isUnlocked({}, 2)).toBe(false); + const p = mergeResult({}, 1, { stars: 1, time: 100, pieces: 5 }); + expect(isUnlocked(p, 2)).toBe(true); + expect(isUnlocked(p, 3)).toBe(false); + expect(isUnlocked(p, 99)).toBe(false); + }); +}); + +describe('the supply layout', () => { + // two 1.6 x 0.8 x 4 racks either side of the spawn, a 3 m ledge, a perch + const racks = { + 'Rack west': { min: [-5, 0, 0.2], max: [-3.4, 0.8, 4.2] }, + 'Rack east': { min: [3.4, 0, 0.2], max: [5, 0.8, 4.2] }, + 'High ledge': { min: [-1.5, 2.7, -3.45], max: [1.5, 3.0, -2.55] }, + 'Star perch': { min: [2.05, 3.2, -2.55], max: [2.75, 3.4, -1.85] } + }; + const sizes = { + cube: [0.6, 0.6, 0.6], plank: [1.4, 0.3, 0.6], beam: [2.0, 0.25, 0.4], wedge: [0.8, 0.6, 0.6], + barrel: [0.6, 0.7, 0.6], arch: [1.2, 0.8, 0.5], lblock: [0.9, 0.5, 0.9], ball: [0.6, 0.6, 0.6], + base: [1.4, 0.35, 1.4], star: [0.5, 0.5, 0.5] + }; + const inside = (/** @type {number[]} */ p, /** @type {any} */ b) => p[0] >= b.min[0] && p[0] <= b.max[0] && p[2] >= b.min[2] && p[2] <= b.max[2]; + it('every level deals every piece onto a rack, nothing hanging off', () => { + for (const l of LEVELS) { + const slots = layoutSupply(l, racks, sizes); + expect(slots.length).toBe(supplyCount(l)); + for (const s of slots) expect(Object.values(racks).some((b) => inside(s.pos, b) && s.pos[1] > b.max[1])).toBe(true); + } + }); + it('the climb level puts three cubes on the HIGH ledge, the summit its star on the perch', () => { + const climb = layoutSupply(levelById(3), racks, sizes); + expect(climb.filter((s) => s.pos[1] > 2.9).length).toBe(3); + const summit = layoutSupply(levelById(12), racks, sizes); + expect(summit.filter((s) => s.shape === 'star').every((s) => inside(s.pos, racks['Star perch']))).toBe(true); + }); + it('slots on one rack never overlap (a stacked pair shares a column, one above the other)', () => { + for (const l of LEVELS) { + const slots = layoutSupply(l, racks, sizes); + for (let i = 0; i < slots.length; i++) + for (let j = i + 1; j < slots.length; j++) { + const a = slots[i], b = slots[j]; + const sameColumn = Math.abs(a.pos[0] - b.pos[0]) < 1e-6 && Math.abs(a.pos[2] - b.pos[2]) < 1e-6; + if (sameColumn) expect(Math.abs(a.pos[1] - b.pos[1])).toBeGreaterThan(0.2); + else expect(Math.hypot(a.pos[0] - b.pos[0], a.pos[2] - b.pos[2])).toBeGreaterThan(0.3); + } + } + }); +}); + +describe('measuring', () => { + const zone = { center: [0, 0, 0], footprint: 1.9, yard: 3.4, top: 0.2 }; + const piece = (/** @type {number[]} */ pos, /** @type {number} */ top, /** @type {any} */ over = {}) => ({ uuid: String(Math.random()), pos, top, bottom: top - 0.6, speed: 0, held: false, ...over }); + it('the tower is the resting, unheld pieces over the zone', () => { + const ps = [piece([0, 0.5, 0], 0.8), piece([0.3, 1.1, 0], 1.4), piece([0, 2, 0], 2.3, { held: true }), piece([0, 1.7, 0], 2.0, { speed: 2 }), piece([4, 0.3, 0], 0.6)]; + expect(towerPieces(ps, zone).length).toBe(2); + expect(towerTop(ps, zone)).toBe(1.4); + expect(towerTop([], zone)).toBe(0.2); + }); + it('a resting piece on the floor outside the yard and off every rack is lost', () => { + const rack = { min: [3.4, 0, 0.2], max: [5, 0.8, 4.2] }; + expect(isOutside({ pos: [0, 0.3, 4], bottom: 0, speed: 0, held: false }, zone, [rack])).toBe(true); + expect(isOutside({ pos: [0, 0.3, 3], bottom: 0, speed: 0, held: false }, zone, [rack])).toBe(false); // in the yard + expect(isOutside({ pos: [4, 1.1, 2], bottom: 0.8, speed: 0, held: false }, zone, [rack])).toBe(false); // on a rack + expect(isOutside({ pos: [0, 0.3, 4], bottom: 0, speed: 1, held: false }, zone, [rack])).toBe(false); // still moving + }); + it('the outline: six cells on the pad, counted by resting boxes', () => { + const cells = outlineCells(zone); + expect(cells.length).toBe(6); + const box = (/** @type {number[]} */ c) => ({ min: [c[0] - 0.3, c[1] - 0.3, c[2] - 0.3], max: [c[0] + 0.3, c[1] + 0.3, c[2] + 0.3] }); + expect(cellsFilled(cells, cells.slice(0, 4).map(box))).toBe(4); + expect(cellsFilled(cells, cells.map(box))).toBe(6); + expect(cellsFilled(cells, cells.map((c) => ({ ...box(c), held: true })))).toBe(0); + }); +}); + +describe('the wind', () => { + const gusts = levelById(8); + it('no wind, no gust; the schedule is deterministic and turns', () => { + expect(gustAt(levelById(1), 100)).toBe(null); + expect(gustAt(gusts, 7.9)).toBe(null); + expect(gustAt(gusts, 8)?.index).toBe(0); + expect(gustAt(gusts, 17.5)?.index).toBe(1); + expect(gust(gusts, 1).at).toBe(17); + expect(gust(gusts, 0).dir).not.toEqual(gust(gusts, 1).dir); + expect(gust(gusts, 3)).toEqual(gust(gusts, 3)); + expect(windFrom([1, 0, 0])).toBe('west'); + expect(windFrom([0, 0, 1])).toBe('north'); + }); +}); + +describe('the judge', () => { + const l1 = levelById(1); // goal 1.8, lost 2, limit 180 + const step = (/** @type {any} */ memo, /** @type {any} */ over) => judge(l1, { elapsed: 10, top: 0.2, anyHeld: false, lost: 0, ...over }, memo); + it('a tower at the goal must HOLD for three seconds, and a hand on a piece restarts the count', () => { + let r = step(null, { elapsed: 10, top: 1.9 }); + expect(r.verdict).toBe(null); + expect(r.hold).toBe(HOLD_SECONDS); + r = step(r.memo, { elapsed: 11.2, top: 1.9 }); + expect(r.hold).toBe(2); + r = step(r.memo, { elapsed: 11.5, top: 1.9, anyHeld: true }); + expect(r.hold).toBe(0); + r = step(r.memo, { elapsed: 12, top: 1.9 }); + r = step(r.memo, { elapsed: 14.9, top: 1.9 }); + expect(r.verdict).toBe(null); + r = step(r.memo, { elapsed: 15.1, top: 1.9 }); + expect(r.verdict).toBe('won'); + }); + it('out of time, out of pieces, a collapse', () => { + expect(step(null, { elapsed: 180 }).verdict).toBe('time'); + expect(step(null, { lost: 3 }).verdict).toBe('lost'); + expect(step(null, { lost: 2 }).verdict).toBe(null); + let r = step(null, { elapsed: 20, top: 1.3 }); + r = step(r.memo, { elapsed: 21.5, top: 0.4 }); + expect(r.verdict).toBe('fell'); + // taking the tower down piece by piece is slower than a fall + r = step(null, { elapsed: 20, top: 1.3 }); + r = step(r.memo, { elapsed: 26, top: 0.4 }); + expect(r.verdict).toBe(null); + }); + it('outline and deliver goals', () => { + const outline = levelById(11); + expect(judge(outline, { elapsed: 5, top: 1.4, filled: 5, cells: 6, anyHeld: false, lost: 0 }, null).hold).toBe(0); + expect(judge(outline, { elapsed: 5, top: 1.4, filled: 6, cells: 6, anyHeld: false, lost: 0 }, null).hold).toBe(3); + const summit = levelById(12); + expect(judge(summit, { elapsed: 5, top: 3.9, star: { top: 3.9, inTower: false }, anyHeld: false, lost: 0 }, null).hold).toBe(0); + expect(judge(summit, { elapsed: 5, top: 3.9, star: { top: 3.9, inTower: true }, anyHeld: false, lost: 0 }, null).hold).toBe(3); + expect(judge(summit, { elapsed: 5, top: 3.9, starLost: true, anyHeld: false, lost: 0 }, null).verdict).toBe('star'); + }); + it('the words', () => { + expect(verdictTitle('won')).toBe('Level complete!'); + expect(statusName(STATUS.fell)).toBe('fell'); + }); +});