Compare commits
2 Commits
5859bc83fa
...
701403ab1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701403ab1d | ||
|
|
6e5f4b5222 |
@ -13,7 +13,14 @@
|
||||
color: #eef4f8; text-shadow: 0 1px 2px #0009;
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
#dev { top: 10px; }
|
||||
/* SPRINT13 gate 3 — the dev line moved out from under Lane B's anchor panel
|
||||
(top:12/left:12; this was top:10/left:10, so they typeset on top of each
|
||||
other all sprint — QA snag list). Top-RIGHT now, and hidden unless asked
|
||||
for: see main.js's dev-line block. A stranger on partly.party was reading
|
||||
"60 fps · storm 12.3s · debris 4" on the front door of a public game, which
|
||||
is a dev build talking to someone who came to play. */
|
||||
#dev { top: 10px; right: 10px; left: auto; text-align: right; display: none; }
|
||||
#dev.on { display: block; }
|
||||
#help { bottom: 10px; opacity: .75; }
|
||||
#banner {
|
||||
position: fixed; top: 38%; left: 0; right: 0;
|
||||
|
||||
@ -11,6 +11,100 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
|
||||
/**
|
||||
* Perpendicular distance in XZ from point `p` to the segment a→b. The camera
|
||||
* cares about the whole segment, not the endpoints: a post the camera stands
|
||||
* clear of can still be planted squarely between it and the player's head.
|
||||
*/
|
||||
function distToSegmentXZ(p, a, b) {
|
||||
const abx = b.x - a.x, abz = b.z - a.z;
|
||||
const len2 = abx * abx + abz * abz;
|
||||
let t = len2 ? ((p.x - a.x) * abx + (p.z - a.z) * abz) / len2 : 0;
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
return Math.hypot(p.x - (a.x + abx * t), p.z - (a.z + abz * t));
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening yaw: point the camera so the first frame is player + what matters,
|
||||
* with nothing skewered through the middle of it. (SPRINT13 gate 2.5.)
|
||||
*
|
||||
* The QA pass put it plainly: "the boot camera puts a pole dead-centre through
|
||||
* the player on every single first impression". Measured rather than eyeballed —
|
||||
* the player spawns at (0,6) on backyard_01 looking up the yard at yaw 0, which
|
||||
* parks the camera near z≈10.4, and post p3 stands at (0,7). Dead between the
|
||||
* camera and the head, every boot, on a public URL.
|
||||
*
|
||||
* Two things this is NOT:
|
||||
* · not a magic yaw. A hand-picked angle is one site's answer, and sites are
|
||||
* data now — site_02 spawns somewhere else with its posts somewhere else, and
|
||||
* a constant tuned against the backyard would frame the corner block by luck.
|
||||
* · not the camera's existing collision. That pulls IN to the first solid in
|
||||
* the way, so a post behind the player trades a skewer for a shoulder-filling
|
||||
* close-up. Both are the bad frame; this picks a different angle instead.
|
||||
*
|
||||
* Sweeps outward from the ideal (camera opposite `lookAt`, so the player stands
|
||||
* in front of the thing they're here to protect) and takes the FIRST yaw with
|
||||
* real clearance — nearest the ideal wins, so the framing gives up as little as
|
||||
* it can.
|
||||
*
|
||||
* TWO NUMBERS HERE ARE MEASURED, not chosen, and both were wrong first:
|
||||
*
|
||||
* `clearance` 0.6 — my first pass asked for 1.1 m and produced a WORSE frame
|
||||
* than the bug it fixed: the camera swung 105° off the garden and stared at a
|
||||
* fence. Reason, measured in the running game: p3 stands at (0,7) and the player
|
||||
* spawns at (0,6), so the post is **1.0 m away** — and the greatest perpendicular
|
||||
* distance any obstacle can ever have from the camera→head line is its own
|
||||
* distance from the player. 1.1 was unreachable by construction. The sweep
|
||||
* dutifully tried all 24 candidates, failed every one, and fell back to
|
||||
* "roomiest", which is a rule that knows nothing about the garden. Clearance is
|
||||
* `sin(off) × 1.0 m` here, so 0.6 buys ~37° of turn and leaves the bed 23° off
|
||||
* centre in a 62° FOV: in frame, next to the player, no pole through the head.
|
||||
*
|
||||
* `maxOff` 90° — the fallback's bound, and the lesson from the same failure. A
|
||||
* frame that has turned more than a quarter-circle off the bed is no longer a
|
||||
* frame of the bed, so it cannot be the "best available" answer no matter how
|
||||
* roomy it is. Past this, a pole in shot is the lesser evil, and the sweep says
|
||||
* so by returning the roomiest angle WITHIN the arc rather than outside it.
|
||||
*
|
||||
* Never throws, never returns NaN: a yard boxed in on every side still gets its
|
||||
* best available frame.
|
||||
*
|
||||
* @param {{x,z}} player
|
||||
* @param {{x,z}} lookAt what the frame should be about — the garden bed
|
||||
* @param {{x,z}[]} obstacles vertical things, by XZ: posts, trunks, structures
|
||||
* @param {object} [opts]
|
||||
* @returns {number} yaw in radians
|
||||
*/
|
||||
export function spawnYawFor(player, lookAt, obstacles = [], opts = {}) {
|
||||
const distance = opts.distance ?? DEFAULTS.distance;
|
||||
const clearance = opts.clearance ?? 0.6;
|
||||
const maxOff = opts.maxOff ?? Math.PI / 2;
|
||||
const steps = opts.steps ?? 24;
|
||||
const ideal = Math.atan2(player.x - lookAt.x, player.z - lookAt.z);
|
||||
|
||||
const clearAt = (yaw) => {
|
||||
const cam = { x: player.x + Math.sin(yaw) * distance, z: player.z + Math.cos(yaw) * distance };
|
||||
let clear = Infinity;
|
||||
for (const o of obstacles) clear = Math.min(clear, distToSegmentXZ(o, player, cam));
|
||||
return clear;
|
||||
};
|
||||
|
||||
let best = ideal, bestClear = clearAt(ideal);
|
||||
if (bestClear >= clearance) return ideal;
|
||||
|
||||
// Alternate outward — +7.5°, −7.5°, +15° … — so "first acceptable" also means
|
||||
// "least turned away from the bed". Bounded by maxOff: see above.
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const mag = (Math.ceil(i / 2) * maxOff) / Math.ceil(steps / 2);
|
||||
if (mag > maxOff) break;
|
||||
const yaw = ideal + (i % 2 ? mag : -mag);
|
||||
const clear = clearAt(yaw);
|
||||
if (clear >= clearance) return yaw;
|
||||
if (clear > bestClear) { bestClear = clear; best = yaw; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Stand-off kept between the camera and whatever it collided with. */
|
||||
const WALL_MARGIN = 0.25;
|
||||
/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
|
||||
@ -108,6 +202,14 @@ export function createCameraRig(domElement, opts = {}) {
|
||||
*/
|
||||
setSolids(list) { solids = list ?? []; },
|
||||
|
||||
/**
|
||||
* What the camera is currently colliding against. A read-only view (spread,
|
||||
* so a caller can't mutate the live array). SPRINT13: added so the
|
||||
* aftermath cloth-swallow fix is observable — "did the dead sail actually
|
||||
* join the solid set" was otherwise a claim with no way to check it.
|
||||
*/
|
||||
get solids() { return [...solids]; },
|
||||
|
||||
/** @param {(x:number, z:number) => number} fn Pass world.heightAt. */
|
||||
setGround(fn) { groundAt = fn ?? (() => -Infinity); },
|
||||
|
||||
|
||||
@ -75,6 +75,22 @@ const CSS = `
|
||||
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
|
||||
color:#93a6b2; opacity:.85; white-space:nowrap; }
|
||||
|
||||
/* SPRINT13 gate 3 — pause and mute.
|
||||
The veil sits UNDER #hud-card (z 30) so a card always wins: pausing can never
|
||||
hide the job sheet or the invoice. It is a veil and not a card because the
|
||||
yard behind it is the point — you paused to LOOK at something. */
|
||||
#hud-pause { position:fixed; inset:0; z-index:20; display:none; place-items:center;
|
||||
background:#060a0d99; backdrop-filter:blur(2px); pointer-events:none; }
|
||||
#hud-pause.on { display:grid; }
|
||||
#hud-pause .word { font:700 34px/1 ui-monospace,Menlo,monospace; letter-spacing:.34em;
|
||||
color:#dde5ea; text-indent:.34em; }
|
||||
#hud-pause .sub { margin-top:10px; text-align:center; color:#8ba0ad; letter-spacing:.1em; }
|
||||
/* Muted has to be visible from anywhere or the player thinks the sound broke. */
|
||||
#hud-mute { position:absolute; top:12px; right:12px; display:none;
|
||||
padding:4px 9px; border:1px solid #3a4a56; border-radius:4px;
|
||||
color:#8ba0ad; letter-spacing:.16em; font-size:11px; }
|
||||
#hud-mute.on { display:block; }
|
||||
|
||||
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
|
||||
/* SPRINT12: E's letterhead + brief + conditions made the tall cards taller
|
||||
than a short window, and a RIG IT button below the fold with no scroll is a
|
||||
@ -184,6 +200,24 @@ const CSS = `
|
||||
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
|
||||
}
|
||||
|
||||
/* --- the splash (SPRINT13 gate 3) ---------------------------------------
|
||||
Built ON E's letterhead rather than beside it: the masthead is the game's
|
||||
name, so the front door is the same sheet of paper as everything else. The
|
||||
only rules here are the ones the letterhead doesn't already own. */
|
||||
#hud-card .splash .letterhead { border-bottom-width: 2px; padding-bottom: 12px; }
|
||||
#hud-card .splash .mark { font-size: 30px; }
|
||||
#hud-card .premise { margin: 14px 0 4px; color: #dde5ea; font-size: 14px; line-height: 1.6; }
|
||||
#hud-card .fineprint { margin: 14px 0 0; color: #6d8494; font-style: italic; line-height: 1.55; }
|
||||
#hud-card .keys { display: grid; gap: 3px; }
|
||||
#hud-card .keyrow { display: flex; gap: 12px; align-items: baseline; padding: 2px 0; }
|
||||
#hud-card .keyrow kbd {
|
||||
flex: none; min-width: 62px; text-align: center; padding: 3px 7px;
|
||||
background: #16222a; border: 1px solid #33454f; border-bottom-width: 2px;
|
||||
border-radius: 4px; color: #cfe0ea; font: inherit; font-size: 11px;
|
||||
}
|
||||
#hud-card .keyrow span { color: #93a6b2; }
|
||||
#hud-card .splash .go { margin-top: 18px; width: 100%; padding: 12px; font-size: 14px; }
|
||||
|
||||
/* --- the pay schedule (job sheet) --------------------------------------
|
||||
This is a QUOTE, not a receipt: it's what the night is worth if you do it
|
||||
right. It reuses your .row shape so it lines up with everything else, but
|
||||
@ -302,6 +336,8 @@ export function createHud(d) {
|
||||
<div class="panel" id="hud-carry" style="display:none"></div>
|
||||
<div id="hud-events"></div>
|
||||
<div id="hud-help"></div>
|
||||
<div id="hud-mute">MUTED</div>
|
||||
<div id="hud-pause"><div class="word">PAUSED</div><div class="sub">P to carry on</div></div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
@ -322,6 +358,9 @@ export function createHud(d) {
|
||||
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
|
||||
const elGardenLabel = $('#hud-garden-label');
|
||||
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
|
||||
const elPause = $('#hud-pause'), elMute = $('#hud-mute');
|
||||
/** Set by main.js once it has asked skyfx whether the bus has a tap. */
|
||||
let muteAvailable = false;
|
||||
|
||||
// --- world-anchored corner load bars ------------------------------------
|
||||
// A bar per corner, floating at the corner it describes. Geometry rather than
|
||||
@ -407,6 +446,96 @@ export function createHud(d) {
|
||||
|
||||
setHelp(text) { elHelp.textContent = text; },
|
||||
|
||||
/** P — the pause veil. Render keeps running; main.js stops the accumulator. */
|
||||
setPaused(on) { elPause.classList.toggle('on', !!on); },
|
||||
|
||||
/** M — reads the state main.js owns. Only shown once the bus is real. */
|
||||
setMuted(on) { elMute.classList.toggle('on', !!on); },
|
||||
|
||||
/**
|
||||
* Does Lane C's audio bus have a mute tap yet? main.js asks skyfx and tells
|
||||
* us. Until it does, M is not advertised on the splash or the help line —
|
||||
* a promised key that does nothing is worse than no key, and this game is
|
||||
* public now. Flips itself on the day C lands `setMute`.
|
||||
*/
|
||||
setAudioMuteAvailable(on) { muteAvailable = !!on; },
|
||||
|
||||
/** The comfort keys, listed only where they're real. See setAudioMuteAvailable. */
|
||||
comfortKeysHint() { return muteAvailable ? 'P pause · M mute' : 'P pause'; },
|
||||
|
||||
/**
|
||||
* THE FRONT DOOR (SPRINT13 gate 3).
|
||||
*
|
||||
* partly.party's arcade can send a stranger straight here, and before this
|
||||
* they landed on a job sheet: an invoice-looking card from a business they'd
|
||||
* never heard of, quoting money for a job nobody had explained, with the
|
||||
* controls appearing only later on prep's help line. The premise of the game
|
||||
* was never once stated on the glass.
|
||||
*
|
||||
* It's one more `.card` — not a menu system. There is nothing to configure:
|
||||
* one week, five jobs, one button. E's letterhead does the work, because the
|
||||
* masthead IS the game's name and the joke lands before the premise does.
|
||||
*
|
||||
* @param {() => void} onStart
|
||||
*/
|
||||
showSplash(onStart) {
|
||||
card.innerHTML = `<div class="card splash">
|
||||
<div class="letterhead">
|
||||
<div>
|
||||
<div class="mark">${BUSINESS.mark}</div>
|
||||
<div class="trade">${BUSINESS.trade}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="premise">Rig shade sails, then keep the client's garden alive
|
||||
through the storm. Five nights, five jobs, one wallet.</p>
|
||||
<div class="sect">THE CONTROLS</div>
|
||||
<div class="keys">
|
||||
${[
|
||||
['WASD', 'walk the yard'],
|
||||
['mouse', 'orbit the camera'],
|
||||
['click', 'pick an anchor · again to unpick'],
|
||||
['[ ]', 'tension the rig — tighter holds shape, and breaks harder'],
|
||||
['S', 'buy a spare shackle'],
|
||||
['E', 'repair a blown corner (ladder if it is up high)'],
|
||||
['C', 'brace against the wind'],
|
||||
['Enter', 'commit the rig and start the night'],
|
||||
...(muteAvailable ? [['P · M', 'pause · mute']] : [['P', 'pause']]),
|
||||
].map(([k, v]) => `<div class="keyrow"><kbd>${k}</kbd><span>${v}</span></div>`).join('')}
|
||||
</div>
|
||||
<p class="fineprint">The forecast is a band, not a promise. Nothing you
|
||||
tie to is as strong as it looks.</p>
|
||||
<button class="go">START THE WEEK</button>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
hud.setVisible(false);
|
||||
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onStart(); });
|
||||
},
|
||||
|
||||
/**
|
||||
* The phone visitor. A public URL gets phones — DESIGN.md's game is WASD and
|
||||
* a mouse orbit, so there is no touch story to tell and pretending otherwise
|
||||
* would waste their time. Before this they got a dead canvas and no reason.
|
||||
*
|
||||
* No START: this is the one card without a way through, on purpose. Sending
|
||||
* someone into a yard they cannot walk is worse than telling them plainly.
|
||||
*/
|
||||
showTouchNotice() {
|
||||
card.innerHTML = `<div class="card splash">
|
||||
<div class="letterhead">
|
||||
<div>
|
||||
<div class="mark">${BUSINESS.mark}</div>
|
||||
<div class="trade">${BUSINESS.trade}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="premise">${BUSINESS.mark} needs a keyboard and a mouse —
|
||||
you walk the yard and orbit the camera to rig a sail, and there's no
|
||||
honest way to do that with a thumb.</p>
|
||||
<p class="fineprint">Grab a laptop and come back. It'll be here.</p>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
hud.setVisible(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {number} t wind/storm time
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
||||
import { createWorld, loadSite } from './world.js';
|
||||
import { createCameraRig } from './camera.js';
|
||||
import { createCameraRig, spawnYawFor } from './camera.js';
|
||||
import { loadStorm, createWind } from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
import { createPlayer } from './player.js';
|
||||
@ -59,6 +59,94 @@ export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm))
|
||||
|
||||
const STORMS = stormsToPreload();
|
||||
|
||||
/**
|
||||
* May Enter commit the rig right now? The ONLY route into `game.advance()` that
|
||||
* a key may take.
|
||||
*
|
||||
* A function, and exported, because it is a security-shaped rule on a PUBLIC
|
||||
* game and the handler that used to hold it is inside boot(), behind a canvas
|
||||
* and a WebGL context — i.e. somewhere no assert can reach. That's precisely how
|
||||
* the exploit shipped: `addEventListener('keydown', …)` fell through to
|
||||
* `game.advance()` in EVERY phase, so Enter DURING a storm jumped straight to a
|
||||
* perfect invoice — the storm never ran, "every corner held", full pay, clean
|
||||
* bonus, +$90 banked on partly.party where strangers could find it. The
|
||||
* integrator hit it in a QA pass, not a test, because there was no seam to test.
|
||||
*
|
||||
* So the rule is a value now. Enter means ONE thing: commit the rig and start
|
||||
* the night.
|
||||
* · `prep` only — forecast and aftermath advance through their own cards, and
|
||||
* a storm advances when it ENDS. Nothing else may move the phase machine.
|
||||
* · not while a card is open — the card owns the keyboard, and its button is
|
||||
* the only way through.
|
||||
*
|
||||
* @param {string} phase game.phase
|
||||
* @param {boolean} cardOpen hud.cardOpen
|
||||
*/
|
||||
export function enterCommits(phase, cardOpen) {
|
||||
return phase === 'prep' && !cardOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this device play HARD YARDS at all? (SPRINT13 gate 3 — the touch notice.)
|
||||
*
|
||||
* The question is NOT "is this a touchscreen", and getting that wrong is the
|
||||
* whole reason this is a function with a comment. `(pointer: coarse)` asks what
|
||||
* the PRIMARY pointer is, so a touchscreen laptop — finger on the glass, mouse
|
||||
* on the desk, perfectly able to play — answers "coarse" and gets locked out by
|
||||
* a courtesy card. That's a worse bug than the dead canvas it replaces, because
|
||||
* it takes the game away from someone who could play it.
|
||||
*
|
||||
* `(any-pointer: fine)` asks the question that actually matters: is there a
|
||||
* mouse, trackpad or stylus attached AT ALL? A phone says no. A tablet says no.
|
||||
* A hybrid laptop says yes and plays. Detection errs toward LETTING PEOPLE IN:
|
||||
* an old browser that doesn't know the query returns `matches: false` for both,
|
||||
* and the `?? true` means an unknown device gets the game rather than a lecture.
|
||||
*
|
||||
* Keyboard can't be feature-detected at all — no browser exposes "is there a
|
||||
* keyboard" — so a fine pointer is the honest proxy, and the notice says what we
|
||||
* need in words rather than pretending to know.
|
||||
*/
|
||||
/**
|
||||
* The fixed-dt accumulator, as a value: how many steps does this frame owe, and
|
||||
* what's left over? (SPRINT13 gate 3 — P.)
|
||||
*
|
||||
* Pulled out of `frame()` for the same reason `enterCommits` was pulled out of
|
||||
* the keydown handler: `frame()` is only ever called by requestAnimationFrame,
|
||||
* and rAF does not fire in a hidden tab — so a pause "test" driven through the
|
||||
* real loop sits there measuring a sim that was already frozen and reports
|
||||
* success. I wrote that probe, and it passed: simT advanced 0 while paused, and
|
||||
* 0 while running. The pause rule is a value now, so it can be checked by
|
||||
* something other than luck.
|
||||
*
|
||||
* PAUSED DRAINS THE ACCUMULATOR rather than leaving it standing. `acc` holds up
|
||||
* to one FIXED_DT of unspent real time; carrying it across a pause spends it on
|
||||
* the first frame after resume — a free sixtieth of a second of storm nobody
|
||||
* asked for, which is invisible right up until two runs of a deterministic sim
|
||||
* disagree about a gust.
|
||||
*
|
||||
* @param {number} acc unspent seconds carried from last frame
|
||||
* @param {number} raw this frame's real delta, already clamped by the caller
|
||||
* @param {boolean} paused
|
||||
* @param {number} [max] step ceiling — a breakpoint must not run 4000 steps
|
||||
* @returns {{steps:number, acc:number}}
|
||||
*/
|
||||
export function accumulate(acc, raw, paused, max = 60) {
|
||||
if (paused) return { steps: 0, acc: 0 };
|
||||
let left = acc + raw;
|
||||
let steps = 0;
|
||||
while (left >= FIXED_DT && steps < max) { steps++; left -= FIXED_DT; }
|
||||
return { steps, acc: left };
|
||||
}
|
||||
|
||||
export function canPlayHere(mm = typeof matchMedia === 'function' ? matchMedia : null) {
|
||||
if (!mm) return true; // no matchMedia (node, old): let them in
|
||||
try {
|
||||
return mm('(any-pointer: fine)').matches ?? true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How fast an unprotected garden dies, in HP per second at full rain.
|
||||
*
|
||||
@ -419,6 +507,13 @@ export async function boot(opts = {}) {
|
||||
// temporal-dead-zone throw. Boot builds them against the same fresh world.
|
||||
let rig;
|
||||
let rigging;
|
||||
// Same reason, same trap (SPRINT13): loadSiteInto now refreshes the camera's
|
||||
// solid set, and that reads sailView — on a first call that happens at boot,
|
||||
// before any cloth exists. Declared up here with the others so it reads
|
||||
// `undefined` (no cloth yet: use the yard's solids) instead of throwing TDZ
|
||||
// from a line whose only crime was being honest about what the camera needs.
|
||||
// Caught by the page going blank, which is the loudest a TDZ ever gets.
|
||||
let sailView = null;
|
||||
const cameraRig = createCameraRig(canvas);
|
||||
const interact = new Interact();
|
||||
|
||||
@ -445,7 +540,7 @@ export async function boot(opts = {}) {
|
||||
await world.dress();
|
||||
currentSite = siteName;
|
||||
|
||||
cameraRig.setSolids(world.solids);
|
||||
refreshCameraSolids();
|
||||
cameraRig.setGround(world.heightAt);
|
||||
// Lane C: local wind effects are per-yard. A venturi (site_02's screaming
|
||||
// gap) and tree shelters both re-register here; empty/absent is a no-op,
|
||||
@ -459,6 +554,23 @@ export async function boot(opts = {}) {
|
||||
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
|
||||
player = await createPlayer(scene, world, cameraRig, { wind, interact });
|
||||
|
||||
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
|
||||
// Done here because the spawn is here: the player and the yard are rebuilt
|
||||
// together, so the frame that introduces them is a property of the site, not
|
||||
// a constant. site_02 spawns somewhere else with its posts somewhere else,
|
||||
// and a yaw hand-tuned against the backyard would frame the corner block by
|
||||
// luck. Obstacles come from the site's own data — every vertical thing a
|
||||
// pole-through-the-head could be.
|
||||
cameraRig.yaw = spawnYawFor(
|
||||
player.pos,
|
||||
{ x: world.gardenBed.x, z: world.gardenBed.z },
|
||||
[
|
||||
...(siteDef.posts ?? []),
|
||||
...(siteDef.trees ?? []),
|
||||
...(siteDef.structures ?? []),
|
||||
].map((o) => ({ x: o.x, z: o.z })),
|
||||
);
|
||||
|
||||
// Re-point everything that captured the old anchor set. Done HERE, right
|
||||
// after the rebuild, rather than in the caller — a caller-side `if (switched)`
|
||||
// was fragile (it broke the moment a debug path had already advanced
|
||||
@ -490,7 +602,28 @@ export async function boot(opts = {}) {
|
||||
|
||||
// --- 3. sail ------------------------------------------------------------
|
||||
rig = new SailRig({ anchors: world.anchors });
|
||||
let sailView = null;
|
||||
|
||||
/**
|
||||
* What the camera may not pass through: the yard's solids, PLUS the cloth.
|
||||
*
|
||||
* SPRINT13 gate 2.5 — "in aftermath the dead draped sail can swallow the
|
||||
* camera whole" (QA pass). The camera has collided with the house since
|
||||
* Sprint 2 for exactly this reason, and the sail was simply never in the list:
|
||||
* while it is up it hangs above head height and nothing notices, but a sail
|
||||
* that has FAILED lies in the yard at head height, which is the one moment the
|
||||
* player most wants to look at it.
|
||||
*
|
||||
* Called from both rebuilds, because they invalidate the list independently:
|
||||
* loadSiteInto() makes new world.solids, rigSail() makes a new cloth. Either
|
||||
* one alone leaves the camera holding a mesh that was disposed.
|
||||
*
|
||||
* The cloth's bounding sphere is recomputed in sailView.update() every frame,
|
||||
* so the raycast reads live geometry rather than the shape it had at rig time.
|
||||
* Cheap: the default grid is 10x10, so ~162 triangles against a whole house.
|
||||
*/
|
||||
function refreshCameraSolids() {
|
||||
cameraRig.setSolids(sailView ? [...world.solids, sailView] : world.solids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the cloth across 4 anchors and (re)build its view.
|
||||
@ -514,6 +647,7 @@ export async function boot(opts = {}) {
|
||||
}
|
||||
sailView = await createSailView(rig);
|
||||
scene.add(sailView);
|
||||
refreshCameraSolids(); // the new cloth; the one it replaced is disposed
|
||||
wireYardActions(interact, { sailRig: rig, world });
|
||||
return sailView;
|
||||
}
|
||||
@ -535,6 +669,9 @@ export async function boot(opts = {}) {
|
||||
let simT = 0;
|
||||
let windT = 0;
|
||||
let acc = 0;
|
||||
/** SPRINT13 gate 3 — the front door's two comfort keys. Neither is sim state: */
|
||||
let paused = false; // P: the accumulator stops; nothing deterministic sees a dt
|
||||
let muted = false; // M: Lane C's bus, once it has a tap (see setMuted)
|
||||
|
||||
function windTime() {
|
||||
if (game.phase === 'storm') return game.phaseT;
|
||||
@ -824,6 +961,17 @@ export async function boot(opts = {}) {
|
||||
events.length = 0;
|
||||
rigging.setActive(to === 'prep');
|
||||
|
||||
// A pause never survives the phase it was taken in. Leaving `paused` true on
|
||||
// the way out of a storm would carry it into the next night, where P is
|
||||
// inert (nothing but the storm has a clock) — so the flag would be stuck on,
|
||||
// unreachable, and the following storm would open frozen with no way to
|
||||
// unfreeze it. That's a soft-lock built out of a comfort feature, which is
|
||||
// the same shape as the night-3 one D found: a state nothing could clear.
|
||||
// makeSky() also hands back a fresh skyfx, so the mute has to be re-applied
|
||||
// or muting silently expires at the phase boundary.
|
||||
if (paused) { paused = false; hud.setPaused(false); }
|
||||
if (muted) setMuted(true);
|
||||
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
|
||||
|
||||
if (to === 'forecast') {
|
||||
@ -850,7 +998,11 @@ export async function boot(opts = {}) {
|
||||
hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
|
||||
}
|
||||
if (to === 'storm') {
|
||||
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
|
||||
// P is only offered where it does something (the storm is the one clock
|
||||
// that doesn't wait), and M only once C's bus has a tap — hud owns that
|
||||
// question, because a help line that lists a dead key is a help line that
|
||||
// teaches a stranger the game is broken.
|
||||
hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
|
||||
}
|
||||
if (to === 'aftermath') {
|
||||
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
|
||||
@ -874,14 +1026,8 @@ export async function boot(opts = {}) {
|
||||
});
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || hud.cardOpen) return;
|
||||
// Enter means ONE thing: commit the rig and start the night. It used to
|
||||
// fall through to game.advance() in every phase, which made Enter DURING
|
||||
// a storm skip straight to a perfect invoice — the storm never ran, every
|
||||
// corner "held", full pay. Found in the SPRINT12 QA pass, live on the
|
||||
// public deploy. Forecast/aftermath advance through their cards (cardOpen
|
||||
// catches those above); nothing else may advance the phase machine.
|
||||
if (game.phase !== 'prep') return;
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!enterCommits(game.phase, hud.cardOpen)) return;
|
||||
if (!rigging.commit()) return;
|
||||
// Off the bank, not off START_BUDGET — see the note on showTonight().
|
||||
spentThisNight = week.bank - rigging.summary.budget;
|
||||
@ -890,9 +1036,72 @@ export async function boot(opts = {}) {
|
||||
game.advance();
|
||||
});
|
||||
|
||||
// Straight into the forecast: the card is the game's front door, and it now
|
||||
// opens on night one of five rather than a difficulty menu.
|
||||
showTonight();
|
||||
/**
|
||||
* P — pause. Storm only, and that is not a limitation, it's the whole scope:
|
||||
* forecast and aftermath are already paused by construction (their cards are
|
||||
* up and the sim isn't running), and prep has no clock. The storm is the only
|
||||
* ninety seconds in the game that don't wait for you, which is exactly why a
|
||||
* stranger on a public URL needs a way to stop it.
|
||||
*/
|
||||
function setPaused(on) {
|
||||
if (game.phase !== 'storm') on = false; // nothing else has a clock to stop
|
||||
if (on === paused) return paused;
|
||||
paused = on;
|
||||
hud.setPaused(paused);
|
||||
return paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* M — mute.
|
||||
*
|
||||
* ⚠️ The bus is Lane C's and it does not have a tap yet. `createAudio()` holds
|
||||
* a `master` gain inside skyfx.js's closure and the only thing it exposes is
|
||||
* `unlockAudio()`, so there is nothing here for main.js to turn down. Asked in
|
||||
* THREADS: `setMute(on)` on the skyfx API, one line against their master gain.
|
||||
*
|
||||
* Until it lands this returns FALSE, and the HUD asks before it advertises M
|
||||
* (see hud.setAudioMuteAvailable). That is deliberate and it is D's lesson from
|
||||
* the night-3 soft-lock: `rigging.setWorld?.(world)` sat in this same file for
|
||||
* a sprint doing NOTHING, silently, because `?.()` on a missing method is a
|
||||
* no-op that looks like a call. Shipping `sky.setMute?.(on)` behind a key the
|
||||
* splash promises would be the same bug with a keycap on it — on a public URL,
|
||||
* where the person pressing M is a stranger who just wants the noise to stop.
|
||||
* So the key is wired, the state is real, and the UI tells the truth about
|
||||
* whether it does anything. It lights up by itself the day C lands the tap.
|
||||
*/
|
||||
function setMuted(on) {
|
||||
muted = !!on;
|
||||
const bus = typeof sky?.setMute === 'function';
|
||||
if (bus) sky.setMute(muted);
|
||||
hud.setMuted(muted);
|
||||
return bus;
|
||||
}
|
||||
|
||||
addEventListener('keydown', (e) => {
|
||||
if (hud.cardOpen) return; // a card owns the keyboard
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'p') setPaused(!paused);
|
||||
else if (k === 'm') setMuted(!muted);
|
||||
});
|
||||
|
||||
// SPRINT13 gate 3 — the front door, in front of the forecast.
|
||||
//
|
||||
// The job sheet used to be the first thing a stranger saw: an invoice-shaped
|
||||
// card from a business they'd never heard of, quoting money for a job nobody
|
||||
// had explained. It's a good SECOND card. partly.party's arcade can drop
|
||||
// someone here cold, so the game says what it is first.
|
||||
//
|
||||
// `opts.splash === false` skips it — the selftest, the dev benches and D's
|
||||
// playtest harness all boot straight into a night, and none of them should
|
||||
// have to click through a door.
|
||||
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
|
||||
if (!canPlayHere()) {
|
||||
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
|
||||
} else if (opts.splash === false) {
|
||||
showTonight();
|
||||
} else {
|
||||
hud.showSplash(() => showTonight());
|
||||
}
|
||||
|
||||
// --- resize -------------------------------------------------------------
|
||||
function resize() {
|
||||
@ -906,9 +1115,26 @@ export async function boot(opts = {}) {
|
||||
|
||||
// --- loop ---------------------------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
const dev = document.getElementById('dev');
|
||||
let frames = 0, fpsT = 0, fps = 0;
|
||||
|
||||
/**
|
||||
* The dev line: fps, phase, sim clock, debris count.
|
||||
*
|
||||
* On by default for anyone developing (localhost, and every lane clone is
|
||||
* localhost) and for anyone who asks with `?dev=1` — D's playtests read it,
|
||||
* and taking it away to tidy a public page would cost more than it saves.
|
||||
* Off everywhere else, which today means partly.party: it's the only thing on
|
||||
* the glass that talks to us instead of the player.
|
||||
*/
|
||||
const devWanted = (() => {
|
||||
try {
|
||||
if (new URLSearchParams(location.search).has('dev')) return true;
|
||||
return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
|
||||
} catch { return false; }
|
||||
})();
|
||||
const dev = devWanted ? document.getElementById('dev') : null;
|
||||
dev?.classList.add('on');
|
||||
|
||||
function step(dt) {
|
||||
game.tick(dt);
|
||||
simT += dt;
|
||||
@ -953,12 +1179,15 @@ export async function boot(opts = {}) {
|
||||
// Clamped so a background tab or a breakpoint doesn't make the sim try to
|
||||
// catch up over thousands of steps and lock the page.
|
||||
const raw = Math.min(0.25, clock.getDelta());
|
||||
acc += raw;
|
||||
let guard = 0;
|
||||
while (acc >= FIXED_DT && guard++ < 60) {
|
||||
step(FIXED_DT);
|
||||
acc -= FIXED_DT;
|
||||
}
|
||||
|
||||
// SPRINT13 gate 3 — P pauses the ACCUMULATOR, not the frame: the sim stops
|
||||
// dead (no step(), so no dt reaches anything deterministic) while the render
|
||||
// keeps going, which is what lets the pause veil sit over a frozen yard
|
||||
// instead of a black screen. The rule itself is `accumulate` — a value, and
|
||||
// tested, because nothing here is reachable from a test.
|
||||
const a = accumulate(acc, raw, paused);
|
||||
for (let i = 0; i < a.steps; i++) step(FIXED_DT);
|
||||
acc = a.acc;
|
||||
|
||||
cameraRig.update(raw, player.pos);
|
||||
sailView?.update();
|
||||
|
||||
@ -4,11 +4,14 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { createWindField } from '../weather.core.js';
|
||||
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
|
||||
import { createCameraRig } from '../camera.js';
|
||||
import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js';
|
||||
import { createCameraRig, spawnYawFor } from '../camera.js';
|
||||
import {
|
||||
CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits,
|
||||
stormsToPreload, verdictFor,
|
||||
} from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
@ -57,6 +60,122 @@ export default async function run(t) {
|
||||
assertEq(checkContract('game', createGame()).join('; '), '');
|
||||
});
|
||||
|
||||
// --- SPRINT13 gate 3: the front door of a PUBLIC game --------------------
|
||||
|
||||
t.test('ENTER CANNOT SKIP THE STORM — the exploit that shipped, pinned', () => {
|
||||
// The QA pass found this live on partly.party: Enter during a storm fell
|
||||
// through to game.advance() and paid a perfect invoice for a storm that
|
||||
// never ran — garden 100%, "every corner held", clean bonus, +$90. It
|
||||
// shipped because the rule lived in a keydown closure inside boot(), behind
|
||||
// a canvas and a WebGL context, where no assert could reach it. The rule is
|
||||
// a value now, so this can fail.
|
||||
//
|
||||
// Exhaustive over the phase machine rather than a spot-check on 'storm': a
|
||||
// sixth phase added later is asserted the day it appears, and the default it
|
||||
// gets is "may not advance", which is the safe direction.
|
||||
for (const phase of PHASES) {
|
||||
assertEq(enterCommits(phase, false), phase === 'prep',
|
||||
`Enter in '${phase}' must ${phase === 'prep' ? 'commit' : 'do NOTHING'}`);
|
||||
}
|
||||
// The storm is the money one — name it, so a reader of a red run knows what
|
||||
// broke without decoding the loop above.
|
||||
assertEq(enterCommits('storm', false), false, 'Enter mid-storm cannot bank a night that never ran');
|
||||
assertEq(enterCommits('aftermath', false), false, 'nor re-advance the invoice');
|
||||
assertEq(enterCommits('forecast', false), false, 'nor skip the job sheet');
|
||||
|
||||
// A card owns the keyboard while it's up: its button is the only way through.
|
||||
for (const phase of PHASES) {
|
||||
assertEq(enterCommits(phase, true), false, `a card is open in '${phase}' — Enter is the card's, not the rig's`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('P stops the sim dead, and gives back no free time on resume', () => {
|
||||
// Tested as a value, and that is the point. The pause lives in frame(),
|
||||
// frame() is only called by requestAnimationFrame, and rAF DOES NOT FIRE IN
|
||||
// A HIDDEN TAB — so my first probe drove the real loop, measured simT
|
||||
// advancing 0 while paused, 0 while running, and reported success. It was
|
||||
// measuring a frozen tab. This can actually fail.
|
||||
const running = accumulate(0, FIXED_DT * 3.5, false);
|
||||
assertEq(running.steps, 3, 'three whole steps out of three and a half');
|
||||
assert(running.acc > 0 && running.acc < FIXED_DT, 'and the half-step is carried, not spent');
|
||||
|
||||
const paused = accumulate(running.acc, FIXED_DT * 10, true);
|
||||
assertEq(paused.steps, 0, 'paused: the sim takes not one step');
|
||||
assertEq(paused.acc, 0, 'and the accumulator is DRAINED — no free sixtieth on resume');
|
||||
|
||||
// The bug the drain prevents, stated: carry `acc` across a pause and the
|
||||
// first frame after resume spends time that elapsed while you were paused.
|
||||
assertEq(accumulate(0, 0, false).steps, 0, 'a zero-delta frame owes nothing');
|
||||
assertEq(accumulate(FIXED_DT * 0.9, FIXED_DT * 0.9, false).steps, 1, 'carried time still adds up to a step');
|
||||
|
||||
// A breakpoint or a background tab must not run thousands of steps at once.
|
||||
assertEq(accumulate(0, 10, false).steps, 60, 'the step ceiling holds');
|
||||
assertEq(accumulate(0, 10, false, 5).steps, 5, 'and it is the caller\'s to set');
|
||||
});
|
||||
|
||||
t.test('the touch notice locks out phones, not touchscreen laptops', () => {
|
||||
// The bug this exists to prevent is the FIX, not the gap: `(pointer: coarse)`
|
||||
// asks what the primary pointer is, so a laptop with a touchscreen answers
|
||||
// "coarse" and gets a courtesy card instead of the game it can perfectly well
|
||||
// play. Taking the game away from someone who could play it is worse than the
|
||||
// dead canvas we're replacing.
|
||||
const mm = (answers) => (q) => ({ matches: !!answers[q] });
|
||||
assertEq(canPlayHere(mm({ '(any-pointer: fine)': true })), true, 'a mouse anywhere means play');
|
||||
assertEq(canPlayHere(mm({ '(any-pointer: fine)': false })), false, 'a phone gets the notice');
|
||||
// The hybrid: primary pointer coarse (finger), but a trackpad exists.
|
||||
assertEq(canPlayHere(mm({ '(any-pointer: fine)': true, '(pointer: coarse)': true })), true,
|
||||
'touchscreen laptop plays — this is the case the naive check gets wrong');
|
||||
// Unknown/old browsers err toward letting people in, never toward a lecture.
|
||||
assertEq(canPlayHere(null), true, 'no matchMedia at all: let them in');
|
||||
assertEq(canPlayHere(() => { throw new Error('nope'); }), true, 'a throwing matchMedia: let them in');
|
||||
assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in');
|
||||
});
|
||||
|
||||
t.test('the spawn frame points at the garden with no pole through the player', () => {
|
||||
// The QA pass: "the boot camera puts a pole dead-centre through the player on
|
||||
// every single first impression." Measured, not eyeballed — these are the
|
||||
// real backyard_01 numbers that produced the bug.
|
||||
const player = { x: 0, z: 6 };
|
||||
const bed = { x: 1, z: 2 };
|
||||
|
||||
// No obstacles: the ideal frame is the camera OPPOSITE the bed, so the player
|
||||
// stands in front of what they're protecting. The view direction (from camera
|
||||
// through the player) must point at the bed.
|
||||
const clean = spawnYawFor(player, bed, []);
|
||||
const viewOff = (yaw) => {
|
||||
const view = { x: -Math.sin(yaw), z: -Math.cos(yaw) };
|
||||
const tb = { x: bed.x - player.x, z: bed.z - player.z };
|
||||
const l = Math.hypot(tb.x, tb.z);
|
||||
return Math.acos(Math.max(-1, Math.min(1, (view.x * tb.x + view.z * tb.z) / (l || 1)))) * 180 / Math.PI;
|
||||
};
|
||||
assertLess(viewOff(clean), 1, 'with nothing in the way, the frame looks straight at the bed');
|
||||
|
||||
// The bug: p3 stands at (0,7), one metre behind the spawn, dead on the ideal
|
||||
// view line. The fix must TURN to clear it — and still keep the bed in a 62°
|
||||
// FOV (< 31° off centre).
|
||||
const withPole = spawnYawFor(player, bed, [{ x: 0, z: 7 }]);
|
||||
const cam = { x: player.x + Math.sin(withPole) * 4.5, z: player.z + Math.cos(withPole) * 4.5 };
|
||||
const abx = cam.x - player.x, abz = cam.z - player.z, l2 = abx * abx + abz * abz;
|
||||
let t = ((0 - player.x) * abx + (7 - player.z) * abz) / l2; t = Math.max(0, Math.min(1, t));
|
||||
const poleClear = Math.hypot(0 - (player.x + abx * t), 7 - (player.z + abz * t));
|
||||
assert(poleClear > 0.5, `the pole is off the view line (${poleClear.toFixed(2)}m), not through the head`);
|
||||
assertLess(viewOff(withPole), 31, 'and the bed is still in frame after the turn');
|
||||
|
||||
// The failure mode I actually shipped first, reproduced from the real yard.
|
||||
// With the full backyard_01 obstacle set and a clearance the geometry cannot
|
||||
// meet, the sweep falls back to "roomiest" — and UNBOUNDED, roomiest swung
|
||||
// 105° off the garden to stare at a fence (the exact number the QA pass would
|
||||
// have seen). maxOff caps the fallback to the 90° arc, and this config brings
|
||||
// it back to 68°. Without the cap this assert goes red at 105°.
|
||||
const yardObstacles = [
|
||||
{ x: -4.5, z: 5.5 }, { x: 4, z: 6 }, { x: 0, z: 7 }, { x: -3.2, z: -1.2 },
|
||||
{ x: -9, z: 2 }, { x: 8, z: -2 },
|
||||
];
|
||||
const unreachable = spawnYawFor(player, bed, yardObstacles, { clearance: 5 });
|
||||
assert(Number.isFinite(unreachable), 'an impossible clearance still yields a finite yaw, never NaN');
|
||||
assertLess(viewOff(unreachable), 91, 'and never turns its back on the bed — the cap holds (105° without it)');
|
||||
});
|
||||
|
||||
// --- the week (SPRINT8 gate 1) -------------------------------------------
|
||||
|
||||
t.test('the week is five escalating nights, each a storm and a site', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user