Merge remote-tracking branch 'origin/lane/d'
# Conflicts: # THREADS.md
This commit is contained in:
commit
9010a2c784
71
THREADS.md
71
THREADS.md
@ -1717,3 +1717,74 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
anchor trio that isn't collinear, or a storm with rain but gentle wind (a "steady soaker", DESIGN.md's
|
||||
slow-pressure level type). Not my call — flagging with numbers. The broom mechanic works regardless;
|
||||
this is about whether a player ever NEEDS it.
|
||||
[D] 2026-07-17 — 🤝 **LANE B — the broom↔pond seam, posted EARLY as §Lane B-2 asks. This is what I need,
|
||||
and it's three members.** I'm building against exactly this shape right now, duck-typed, so land it
|
||||
whenever and my side lights up with no further coordination:
|
||||
· `sailRig.ponds -> [{ node, mass, pos }]` — every pool worth pointing a broom at. `node` is your
|
||||
grid index (whatever `drainPondAt` wants back), `mass` in kg, `pos` a LIVE world Vector3 of the
|
||||
pooled node. **`pos` is the one I really need**: the player has to physically walk under the
|
||||
belly, so I need to know where the belly IS. Empty array when nothing's pooled.
|
||||
· `sailRig.drainPondAt(node) -> number` — **please return the kg actually dumped.** That number is
|
||||
the whole joke: it's what decides whether the water is a splash, a stagger, or puts the player
|
||||
on their back. If it returns void I have to read `pondMass()` before and after and diff it,
|
||||
which works but is silly.
|
||||
· `sailRig.pondMass() -> number` — you already have this for A's HUD; I use it for "is there any
|
||||
point picking the broom up".
|
||||
Your spec says drain takes ~1.5 s — mine is a 1.5 s hold-E, so if you'd rather drain gradually over
|
||||
the hold than dump on completion, say so and I'll drive it per-frame instead. **Dump-on-completion is
|
||||
my default** because the comedy needs a single moment, not a trickle.
|
||||
Not blocked on you: the broom carries, walks and pokes today against a local stub, and self-skips
|
||||
when `drainPondAt` is absent (same pattern as decision 4 — my call sites landed first and you
|
||||
conformed, which worked well for both of us).
|
||||
|
||||
[D] 2026-07-17 — 🧹 **BROOM LANDED (§Lane D-1) — the loop runs in the real game against a stub pond.**
|
||||
`web/world/js/broom.js` (Lane D). Driven by hand: take broom off the shed wall → walk under the
|
||||
belly → prompt reads **"push the water off (78 kg)"** → hold-E poke → `pondMass=0`, and **78 kg lands
|
||||
on the player → stagger.** Self-wires from createPlayer; reads the live rig off `interact.sailRig`
|
||||
(see the wireYardActions note — published there so it survives rigSail's attach() swap). Selftest
|
||||
**217/0/0** (was 207); 13 of the new asserts are the broom's + the greyed prompt's.
|
||||
Everything deferred to E's baked metadata — `carry_type`, `poke_tip.use` (bristle end, "won't hole
|
||||
the cloth"), `anim_hint` (reuse Crank). Nothing invented. E, the asset did the design work; thank you.
|
||||
|
||||
[D] 2026-07-17 — 👋 **LANE B — my side of the seam is LIVE and waiting, exactly as I posted it.** The
|
||||
broom already calls `sailRig.ponds` (reads `{node, mass, pos}`) and `sailRig.drainPondAt(node)`
|
||||
(uses the returned kg to size the douse). Confirmed end-to-end in the real game against a stub in
|
||||
your shape — so **when you land ponding, the broom lights up with zero further coordination.** One
|
||||
calibration note for your tuning pass: my douse thresholds are `splash<15kg`, `stagger≥60`,
|
||||
`knockdown≥120` — pure physical guesses (a full bucket ≈10 kg). Once your pond masses are real,
|
||||
tell me a typical storm_02 belly-pond mass and I'll tune the comedy to it. Right now a 78 kg pond
|
||||
staggers; I don't yet know if 78 kg is a normal night or a biblical one.
|
||||
|
||||
[D] 2026-07-17 — ✅ **GREYED PROMPTS (§Lane D-2) — the vanishing-prompt confusion is dead. LANE A, your
|
||||
HUD hook is ready.** `interact.step()` now returns `{target, label, progress, holding, usable}`. When
|
||||
`usable:false` the prompt is a REASON, not an offer — grey it, no radial. The label is already the
|
||||
sentence: "hands full", "out of reach — needs the ladder", "the fascia needs the ladder — it's by
|
||||
the shed", "you need the broom". `interact.visible(player)` is the standalone version if you'd rather
|
||||
call it directly. Verified in the real game: walk to the shed table holding the ladder and it now says
|
||||
**"hands full" greyed** instead of showing nothing, while `nearest()` (what hold-E fires on) still
|
||||
correctly refuses it — display and action stay separate. Asserted three ways.
|
||||
|
||||
[D] 2026-07-17 — 💬 **FEEL PASS — I played a round (the §Lane D-3 hail+ponding pass can't fully run yet;
|
||||
hailApi and pondApi are both still false on main, so this is the loop that exists + a stub pond):**
|
||||
1. **The face is real and it's good.** Forecast → prep table (11 anchors, budget, tension, anchor
|
||||
rings) → 90 s wild night → a proper aftermath card (garden %, corners, hardware, collateral,
|
||||
verdict, PLAY AGAIN). It plays like a game now, not a tech demo.
|
||||
2. **The feel pass immediately re-confirmed decision 13's necessity, live.** A flawless rig — 4/4,
|
||||
"Every corner held", nothing lost — scored **garden 50%.** Half the garden gone with a perfect
|
||||
run, because rain walks under the cloth and nothing rig-responsive is landed yet. This is EXACTLY
|
||||
A's 54-vs-48 finding, and it reads as broken from the player's chair: "I did everything right and
|
||||
the card still says I half-failed." Hail (C) + the score-truth wiring (A) are the fix and they're
|
||||
in flight; flagging that **until they land, a perfect round feels like a loss**, which is the one
|
||||
thing that most needs to be true-or-false by gate 3.
|
||||
3. **The broom is funny even solo.** 78 kg on the head → stagger → the player reels back from under
|
||||
the sail. It'll be funnier when it's a pond you watched grow and dreaded, not one I injected —
|
||||
but the beat already lands. Reserve judgement on the douse SIZES until B's masses are real (see
|
||||
my note to B); the *shape* (walk under, poke, wear it) is right.
|
||||
4. **Greyed prompts fixed the thing that read as broken last sprint.** Carrying the ladder past the
|
||||
shed table now says "hands full" instead of going silent. Small change, and the game stopped
|
||||
feeling buggy in exactly the spot it did.
|
||||
|
||||
[D] 2026-07-17 — 🧵 **wireYardActions now publishes `interact.sailRig`** (the live rig, refreshed on every
|
||||
rigSail/attach) so Lane-D systems that need the rig but are built by createPlayer — the broom needs
|
||||
`ponds`/`drainPondAt` — can read it without main.js threading it through. Same pattern and same reason
|
||||
as `interact.ladder`. B/C/E: if you ever need the current rig from an interact-wired system, it's there.
|
||||
|
||||
188
web/world/js/broom.js
Normal file
188
web/world/js/broom.js
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* broom.js — DESIGN.md's funniest correct mechanic. (Lane D, SPRINT5 §Lane D-1)
|
||||
*
|
||||
* A flat sail pools water. The water is heavier than anything else in the game and it will pull the
|
||||
* rig down. The fix is a bloke with a broom walking under the belly and poking it upward — at which
|
||||
* point forty kilos of cold water arrives on his head. That is both the correct engineering answer
|
||||
* and the joke, and they are the same thing, which is the best kind of mechanic.
|
||||
*
|
||||
* Everything here defers to the asset. E baked the intent into broom_01_v1.glb:
|
||||
* grip_anchor extras.carry_type = "broom"
|
||||
* poke_tip extras.use = "push the pond up from under the sail; soft end, won't hole the cloth"
|
||||
* — on the BRISTLE end, deliberately: a broomstick jabbed at a loaded sail holes it.
|
||||
* root extras.anim_hint = "reuse Crank/Dig for the poke — no new Mixamo needed"
|
||||
* so the carry type, the working end and the animation are all read, not invented.
|
||||
*
|
||||
* Seam with Lane B (posted in THREADS before either of us built): sailRig.ponds -> [{node,mass,pos}]
|
||||
* and drainPondAt(node) -> kg dumped. Duck-typed: with no ponding landed the broom still carries,
|
||||
* walks and refuses to poke thin air, and the moment B lands it the whole thing lights up.
|
||||
*/
|
||||
// No top-level THREE/GLTFLoader import, deliberately: the vendored addons import the bare specifier
|
||||
// 'three', which only resolves under index.html's importmap — so importing them here would drag the
|
||||
// whole GL chain into d.test.js and cost this lane its headless suite (node resolves relative paths
|
||||
// only). The pond logic below is the part worth asserting and it is pure; the view is loaded
|
||||
// dynamically, which never happens outside a browser.
|
||||
export const BROOM_URL = './models/broom_01_v1.glb';
|
||||
|
||||
export const BROOM_TUNE = {
|
||||
pokeSecs: 1.5, // matches B's "drain over ~1.5 s"
|
||||
reachUp: 3.2, // m — how high overhead a pond can be and still be pokeable from the grass
|
||||
standRadius: 2.0, // m — how near the pond's ground shadow you must be
|
||||
|
||||
// What lands on you. Calibrate once B's masses are real — these are physical guesses, not measured:
|
||||
// a full bucket is ~10 kg, so a splash is nothing, half a bathtub staggers you, and a bathtub
|
||||
// puts you down. Flagged in THREADS for the tuning pass.
|
||||
splashKg: 15, // below this it's just cold and funny
|
||||
staggerKg: 60, // above this you lose your footing
|
||||
// above staggerKg*2 → flat on your back
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {THREE.Object3D} scene
|
||||
* @param {object} world contracts World (dressed)
|
||||
* @param {object} interact Lane D's Interact
|
||||
* @param {object} player PlayerSim
|
||||
* @param {object} getRig () => sailRig — a getter, because rigSail() REPLACES the rig object
|
||||
*/
|
||||
export function createBroom(scene, world, interact, player, getRig) {
|
||||
const state = { carried: false, view: null, tune: { ...BROOM_TUNE } };
|
||||
|
||||
// Home: against the shed wall. E ships it standing on its head, which is how it lives there.
|
||||
const home = { x: 8.2, y: 0, z: 7.0 };
|
||||
if (world.shedTable && world.shedTable.pos) {
|
||||
home.x = world.shedTable.pos.x - 0.8;
|
||||
home.z = world.shedTable.pos.z + 1.0;
|
||||
}
|
||||
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
|
||||
|
||||
if (scene) {
|
||||
import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {
|
||||
new GLTFLoader().load(BROOM_URL, (g) => {
|
||||
g.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
|
||||
state.view = g.scene;
|
||||
scene.add(g.scene);
|
||||
sync();
|
||||
}, undefined, () => { /* no asset: the mechanic still runs, you just can't see the broom */ });
|
||||
}).catch(() => { /* headless (selftest/node): logic only, no view */ });
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (!state.view) return;
|
||||
state.view.visible = !state.carried;
|
||||
state.view.position.set(home.x, home.y, home.z);
|
||||
state.view.rotation.set(0, 0.9, 0.16); // slouched against the shed wall
|
||||
}
|
||||
|
||||
/** Every pond Lane B is reporting, or [] until they land it. */
|
||||
const ponds = () => {
|
||||
const rig = getRig && getRig();
|
||||
return (rig && Array.isArray(rig.ponds)) ? rig.ponds : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* The pond this player could actually poke: near enough in plan, low enough overhead.
|
||||
* Picks the HEAVIEST reachable one rather than the nearest — if you're standing under two, the
|
||||
* one about to break the rig is the one you meant.
|
||||
*/
|
||||
function targetPond() {
|
||||
let best = null;
|
||||
for (const p of ponds()) {
|
||||
if (!p || !p.pos || !(p.mass > 0)) continue;
|
||||
const d = Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z);
|
||||
const up = p.pos.y - (player.pos.y + player.climbY);
|
||||
if (d > state.tune.standRadius || up > state.tune.reachUp || up < 0) continue;
|
||||
if (!best || p.mass > best.mass) best = p;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Where to stand: the pond's shadow on the grass. */
|
||||
const pokeSpot = () => {
|
||||
const p = ponds().reduce((a, b) => (!a || (b && b.mass > a.mass) ? b : a), null);
|
||||
if (!p || !p.pos || !(p.mass > 0)) return null;
|
||||
return { x: p.pos.x, y: 0, z: p.pos.z };
|
||||
};
|
||||
|
||||
const wired = [];
|
||||
|
||||
// 1. take the broom off the shed wall — a third carry type, so it queues behind the same hands
|
||||
wired.push(interact.register({
|
||||
id: 'broom_take',
|
||||
pos: () => (state.carried ? null : home),
|
||||
radius: 1.5,
|
||||
holdSecs: 0.7,
|
||||
clip: 'PickUp',
|
||||
label: (p) => (p.carrying ? 'hands full' : 'take the broom'),
|
||||
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
|
||||
onDone: (p, t) => { state.carried = true; p.pickUp('broom', t); sync(); },
|
||||
}));
|
||||
|
||||
// 2. put it back
|
||||
wired.push(interact.register({
|
||||
id: 'broom_drop',
|
||||
pos: () => (state.carried ? { x: home.x, y: home.y, z: home.z } : null),
|
||||
radius: 1.5,
|
||||
holdSecs: 0.4,
|
||||
clip: 'PickUp',
|
||||
label: 'put the broom back',
|
||||
canUse: (p) => state.carried && p.carrying === 'broom',
|
||||
onDone: (p, t) => { state.carried = false; p.drop(t); sync(); },
|
||||
}));
|
||||
|
||||
// 3. THE POKE. Stand under the belly, push up, wear it.
|
||||
wired.push(interact.register({
|
||||
id: 'broom_poke',
|
||||
pos: pokeSpot,
|
||||
radius: state.tune.standRadius,
|
||||
holdSecs: state.tune.pokeSecs,
|
||||
clip: 'Crank', // E's anim_hint — no new Mixamo needed
|
||||
label: (p) => {
|
||||
if (p.carrying !== 'broom') return 'you need the broom';
|
||||
const pond = targetPond();
|
||||
if (!pond) return 'nothing pooling here';
|
||||
return `push the water off (${Math.round(pond.mass)} kg)`;
|
||||
},
|
||||
// physical gates only — never player.state (see interact.register's note; it cancels its own hold)
|
||||
canUse: (p) => {
|
||||
const rig = getRig && getRig();
|
||||
return p.carrying === 'broom' && !!(rig && rig.drainPondAt) && !!targetPond();
|
||||
},
|
||||
onDone: (p, t) => {
|
||||
const rig = getRig && getRig();
|
||||
const pond = targetPond();
|
||||
if (!rig || !pond) return;
|
||||
// B returns the kg actually dumped; fall back to diffing pondMass() if they'd rather not
|
||||
let kg = rig.drainPondAt(pond.node);
|
||||
if (typeof kg !== 'number') kg = pond.mass;
|
||||
onWater(p, kg, pond, t);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* The payoff. All of it lands on the player, because they are standing directly underneath it —
|
||||
* that is not a bug in the plan, it IS the plan.
|
||||
*/
|
||||
function onWater(p, kg, pond, t) {
|
||||
p.events.push({ type: 'doused', kg, t });
|
||||
const T = state.tune;
|
||||
if (kg >= T.staggerKg * 2) {
|
||||
// downward and behind: a bathtub arriving on your head does not blow you downwind
|
||||
p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing));
|
||||
} else if (kg >= T.staggerKg) {
|
||||
p.staggerHit(t);
|
||||
}
|
||||
// below splashKg: you just get wet, which is its own reward
|
||||
}
|
||||
|
||||
return {
|
||||
get carried() { return state.carried; },
|
||||
get home() { return home; },
|
||||
tune: state.tune,
|
||||
ponds,
|
||||
targetPond,
|
||||
pokeSpot,
|
||||
onWater,
|
||||
update() { sync(); },
|
||||
dispose() { wired.forEach((un) => un()); if (state.view) scene.remove(state.view); },
|
||||
};
|
||||
}
|
||||
@ -62,7 +62,7 @@ export class Interact {
|
||||
return !target.canUse || !!target.canUse(player);
|
||||
}
|
||||
|
||||
/** Nearest registered target in range whose canUse() passes. */
|
||||
/** Nearest registered target in range whose canUse() passes. This is what a hold-E acts on. */
|
||||
nearest(player) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const target of this.targets.values()) {
|
||||
@ -74,6 +74,31 @@ export class Interact {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HUD should SHOW, which is not the same question as what E acts on.
|
||||
*
|
||||
* A usable action always wins. But when nothing is usable, this returns the nearest action that
|
||||
* is merely unavailable, so the prompt can say WHY instead of vanishing. That distinction came
|
||||
* out of playing it: walking to the shed table with the ladder in your hands made the prompt
|
||||
* disappear, which reads as a broken game rather than a full pair of hands — and every target
|
||||
* already had a perfectly good sentence sitting in its `label`, unreachable, because canUse had
|
||||
* filtered it out before the label was ever asked.
|
||||
*
|
||||
* @returns {{target, usable, label}|null}
|
||||
*/
|
||||
visible(player) {
|
||||
const usable = this.nearest(player);
|
||||
if (usable) return { target: usable, usable: true, label: this.labelOf(usable, player) };
|
||||
let best = null, bestD = Infinity;
|
||||
for (const target of this.targets.values()) {
|
||||
const p = typeof target.pos === 'function' ? target.pos() : target.pos;
|
||||
if (!p) continue;
|
||||
const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
|
||||
if (d <= target.radius && d < bestD) { best = target; bestD = d; }
|
||||
}
|
||||
return best ? { target: best, usable: false, label: this.labelOf(best, player) } : null;
|
||||
}
|
||||
|
||||
cancel(t, player) {
|
||||
if (!this.active) return;
|
||||
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
|
||||
@ -88,7 +113,10 @@ export class Interact {
|
||||
* @param {number} dt @param {number} t
|
||||
* @param {PlayerSim} player
|
||||
* @param {boolean} holding is E held this frame
|
||||
* @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
|
||||
* @returns {{target, progress, label, holding, usable}} for hud.js to draw the prompt + radial.
|
||||
* `usable:false` means the prompt is a REASON, not an offer — grey it out and don't show a
|
||||
* radial. Lane A: this is the greyed-prompt surface I offered; `label` is already the sentence
|
||||
* ("hands full", "out of reach — needs the ladder", "you need the broom").
|
||||
*/
|
||||
step(dt, t, player, holding) {
|
||||
// One press, one action: a completed hold latches until E is released. Without this, a held key
|
||||
@ -126,12 +154,22 @@ export class Interact {
|
||||
}
|
||||
}
|
||||
|
||||
const shown = this.active || near;
|
||||
if (this.active) {
|
||||
return {
|
||||
target: this.active,
|
||||
progress: this.progress,
|
||||
label: this.labelOf(this.active, player),
|
||||
holding: true,
|
||||
usable: true,
|
||||
};
|
||||
}
|
||||
const shown = this.visible(player);
|
||||
return {
|
||||
target: shown,
|
||||
progress: this.progress,
|
||||
label: shown ? this.labelOf(shown, player) : '',
|
||||
holding: !!this.active,
|
||||
target: shown ? shown.target : null,
|
||||
progress: 0,
|
||||
label: shown ? shown.label : '',
|
||||
holding: false,
|
||||
usable: !!(shown && shown.usable),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -158,6 +196,11 @@ export function wireYardActions(interact, deps = {}) {
|
||||
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
|
||||
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
|
||||
const ladder = deps.ladder || interact.ladder || null;
|
||||
// Publish the CURRENT rig for lane-D systems built before it exists (the broom needs ponds +
|
||||
// drainPondAt). main.js re-calls wireYardActions through rigSail() every time attach() replaces
|
||||
// the rig object, so reading `interact.sailRig` is always the live one — which is the same reason
|
||||
// the corner closures below read by index rather than capturing.
|
||||
interact.sailRig = sailRig || null;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
const anchorOf = (i) => {
|
||||
|
||||
@ -141,7 +141,13 @@ export function createLadder(scene, world, interact, player) {
|
||||
radius: PLACE_RANGE,
|
||||
holdSecs: 1.0,
|
||||
clip: 'PickUp',
|
||||
label: `set the ladder under ${a.id}`,
|
||||
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
|
||||
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
|
||||
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
|
||||
// single most useful sentence in the game.
|
||||
label: (p) => (p.carrying === 'ladder'
|
||||
? `set the ladder under ${a.id}`
|
||||
: 'the fascia needs the ladder — it\'s by the shed'),
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
|
||||
@ -17,8 +17,9 @@ import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js'
|
||||
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
|
||||
import { createLadder } from './ladder.js';
|
||||
import { createBroom } from './broom.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -287,6 +288,11 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
|
||||
const ladder = (opts.ladder === false || !opts.interact)
|
||||
? null : createLadder(scene, world, opts.interact, sim);
|
||||
// The broom needs the sail rig, which createPlayer isn't handed — but wireYardActions is, and
|
||||
// main.js re-calls it through rigSail() whenever attach() swaps the rig. So read it live off
|
||||
// interact rather than capturing a rig that's about to be replaced.
|
||||
const broom = (opts.broom === false || !opts.interact)
|
||||
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
@ -299,6 +305,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (ladder) ladder.update(dt, t, input);
|
||||
if (broom) broom.update(dt, t, input);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
@ -308,8 +315,13 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
sim,
|
||||
view,
|
||||
ladder,
|
||||
broom,
|
||||
keyboard,
|
||||
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
|
||||
dispose() {
|
||||
keyboard.dispose(); view.dispose();
|
||||
if (ladder) ladder.dispose();
|
||||
if (broom) broom.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
|
||||
import { Interact, wireYardActions } from '../interact.js';
|
||||
import { createBroom, BROOM_TUNE } from '../broom.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
@ -517,6 +518,157 @@ export default async function run(t) {
|
||||
assertEq(p.climbY, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the greyed-prompt surface
|
||||
t.test('prompt: a usable action always wins over a reason', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 3, canUse: () => false,
|
||||
label: 'hands full' });
|
||||
it.register({ id: 'open', pos: { x: 0, y: 0, z: 1 }, radius: 3, label: 'take a spare' });
|
||||
const v = it.visible(p);
|
||||
assertEq(v.target.id, 'open', 'the thing you CAN do is the thing you are offered');
|
||||
assert(v.usable, 'and it is offered, not explained');
|
||||
});
|
||||
|
||||
t.test('prompt: an unavailable action explains itself instead of vanishing', () => {
|
||||
// The bug this fixes, found by playing: walk to the shed table carrying the ladder and the
|
||||
// prompt disappeared, because canUse filtered the target out of nearest() before its label
|
||||
// could ever say "hands full". No prompt reads as a broken game; a greyed one reads as a rule.
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
it.register({ id: 'spare_table', pos: { x: 0, y: 0, z: 0 }, radius: 3,
|
||||
label: (pl) => (pl.carrying ? 'hands full' : 'take a spare'),
|
||||
canUse: (pl) => !pl.carrying });
|
||||
|
||||
assertEq(it.visible(p).label, 'take a spare', 'empty-handed: an offer');
|
||||
assert(it.visible(p).usable);
|
||||
|
||||
p.carrying = 'ladder';
|
||||
const v = it.visible(p);
|
||||
assert(!!v, 'carrying something, the prompt must NOT vanish');
|
||||
assertEq(v.label, 'hands full', 'it says why');
|
||||
assert(!v.usable, 'and is flagged unusable so the HUD can grey it');
|
||||
assertEq(it.nearest(p), null, 'while nearest() — what E acts on — still correctly refuses it');
|
||||
});
|
||||
|
||||
t.test('prompt: step() reports usable so the HUD can grey the radial', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 3, holdSecs: 1, label: 'do it',
|
||||
canUse: (pl) => !pl.carrying });
|
||||
p.carrying = 'broom';
|
||||
let r = it.step(DT, 0, p, true);
|
||||
assert(!r.usable, 'unusable');
|
||||
assertEq(r.progress, 0, 'and no radial creeps up on an action that cannot run');
|
||||
p.carrying = null;
|
||||
r = it.step(DT, 0.1, p, true);
|
||||
assert(r.usable, 'usable once the hands are free');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1)
|
||||
// Lane B's ponding isn't landed yet, so this stub is the seam I posted in THREADS:
|
||||
// ponds -> [{node, mass, pos}], drainPondAt(node) -> kg dumped.
|
||||
const stubRig = (ponds = []) => ({
|
||||
ponds,
|
||||
pondMass: () => ponds.reduce((s, p) => s + p.mass, 0),
|
||||
drainPondAt(node) {
|
||||
const p = ponds.find((x) => x.node === node);
|
||||
if (!p) return 0;
|
||||
const kg = p.mass;
|
||||
p.mass = 0; // B's sail springs back on its own once the weight is gone
|
||||
return kg;
|
||||
},
|
||||
});
|
||||
|
||||
t.test('broom: is a third carry type and queues behind the same hands', () => {
|
||||
const p = new PlayerSim();
|
||||
assertEq(p.pickUp('broom'), true, 'take the broom');
|
||||
assertEq(p.pickUp('spare'), false, 'no spare as well');
|
||||
assertEq(p.pickUp('ladder'), false, 'no ladder as well');
|
||||
assertEq(clipFor(p), 'CarryIdle', 'and you read as carrying');
|
||||
assertEq(p.drop(), 'broom');
|
||||
});
|
||||
|
||||
t.test('broom: refuses to poke thin air, and refuses without the broom', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const rig = stubRig([]); // nothing pooling
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
|
||||
p.carrying = 'broom';
|
||||
assertEq(b.targetPond(), null, 'no pond, nothing to poke');
|
||||
assertEq(it.nearest(p), null, 'and no offer');
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
t.test('broom: poke drains the pond Lane B reports, and the water lands on YOU', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const rig = stubRig([{ node: 44, mass: 30, pos: { x: 0, y: 2.6, z: 0 } }]);
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
|
||||
p.carrying = 'broom';
|
||||
|
||||
const pond = b.targetPond();
|
||||
assert(!!pond, 'standing under the belly, there is a pond to poke');
|
||||
assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered');
|
||||
assert(/30 kg/.test(it.labelOf(it.nearest(p), p)), 'the prompt tells you how much is up there');
|
||||
|
||||
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assertEq(rig.pondMass(), 0, 'the pond is gone');
|
||||
assert(p.events.some((e) => e.type === 'doused' && e.kg === 30), 'and it went over the player');
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
t.test('broom: the size of the pond decides the size of the joke', () => {
|
||||
const mk = (kg) => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const rig = stubRig([{ node: 1, mass: kg, pos: { x: 0, y: 2.6, z: 0 } }]);
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
|
||||
p.carrying = 'broom';
|
||||
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
b.dispose();
|
||||
return p.state;
|
||||
};
|
||||
assertEq(mk(5), 'idle', 'a splash just makes you wet');
|
||||
assertEq(mk(BROOM_TUNE.staggerKg + 5), 'stagger', 'half a bathtub breaks your stride');
|
||||
assertEq(mk(BROOM_TUNE.staggerKg * 2 + 5), 'knocked', 'a bathtub puts you on your back');
|
||||
});
|
||||
|
||||
t.test('broom: picks the heaviest pond overhead, not the nearest', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const rig = stubRig([
|
||||
{ node: 1, mass: 8, pos: { x: 0.2, y: 2.6, z: 0 } }, // nearer
|
||||
{ node: 2, mass: 90, pos: { x: 1.4, y: 2.6, z: 0 } }, // heavier — the one breaking the rig
|
||||
]);
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
|
||||
p.carrying = 'broom';
|
||||
assertEq(b.targetPond().node, 2, 'if you are under two, you meant the one about to kill you');
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
t.test('broom: a pond out of reach overhead cannot be poked from the grass', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const rig = stubRig([{ node: 1, mass: 40, pos: { x: 0, y: 9, z: 0 } }]); // 9 m up
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
|
||||
p.carrying = 'broom';
|
||||
assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9');
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
t.test('broom: self-skips cleanly until Lane B lands drainPondAt', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const it = new Interact();
|
||||
const noPonding = { }; // a rig with no ponding at all
|
||||
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding);
|
||||
p.carrying = 'broom';
|
||||
assertEq(b.ponds().length, 0, 'no ponds reported');
|
||||
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
|
||||
assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws');
|
||||
b.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- solids collision
|
||||
t.test('collision: solids stop you, and the pushout slides you along them', () => {
|
||||
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
|
||||
|
||||
Loading…
Reference in New Issue
Block a user