The joint bust (ChoiceOverlay, corruption verb, report accounts) + the glassie run (kitchen, rack carry, wobble physics, glass smash)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7803873825
commit
389376c2f1
@ -1157,3 +1157,46 @@ smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles.
|
||||
**Next:** John plays it (economy feel + ear pass, M works live); re-roll any
|
||||
sprite that bugs him; regular step-up lines and the smoking-door slip-in stay
|
||||
the next code nibbles.
|
||||
|
||||
---
|
||||
### SESSION — SCENARIOS: THE JOINT & THE RACK (Fable, solo) — 2026-07-21 00:50
|
||||
**Branch/commits:** main; deployed live.
|
||||
**Done:**
|
||||
- **docs/SCENARIOS.md** — the idea shelf: characters (off-duty cop, celebrity
|
||||
incognito, twins, sovereign citizen, noise-complaint neighbour, lost hen's
|
||||
party, ex-doorman tutorial character), interactions (ID passback, 1:30
|
||||
LOCKOUT LAW as a late-game phase, vomit protocol, water station, kebab run),
|
||||
all held to §4.3 discipline. Two items implemented this session:
|
||||
- **THE JOINT BUST** (smoking yard): 'joint' is real contraband (severity 2 —
|
||||
it also turns up in rope pat-downs now). E on a committed smoker who smells
|
||||
herbal opens the new reusable **ChoiceOverlay** (three verbs + doll +
|
||||
keyboard/pointer, built for every future floor scenario):
|
||||
- BIN IT: by the book; the yard grumbles (+1 aggro); truth-logged.
|
||||
- POCKET IT — **the game's first corruption verb**: nothing happens now;
|
||||
50% a Dazza sting lands later ("a punter says SECURITY is holding");
|
||||
and the incident sits in the log with report-form accounts where the
|
||||
TRUTHFUL option is a confession (interest weight 9 — it WILL come up).
|
||||
- DIDN'T SEE IT: the yard exhales (+1 vibe, smoker +0.08 intox), and
|
||||
"no action taken" is still on the record.
|
||||
- **THE GLASSIE RUN** (the role ladder's glassie fantasy as a floor task):
|
||||
kitchen room carved behind the bar's west end (dishwasher prop with green
|
||||
glow, racks, door), a full rack spawns at the bar's EAST end (~22 min
|
||||
respawn, amber marker). Carry it the length of the venue: crowd bumps and
|
||||
hard steering build WOBBLE (pure physics in floor/glassie.ts, 5 tests);
|
||||
wobble breaks glasses one smash at a time (new Sfx 'glassSmash' voice);
|
||||
deliver survivors for vibe. Drop the lot: "The crowd applauds. Someone
|
||||
yells TAXI." While carrying, the ONLY interaction in the world is the
|
||||
dishwasher — the rack owns your hands, the door owns Kayden.
|
||||
**Verified in-browser**: full rack run (took rack → stressed → 5/6 delivered,
|
||||
incident logged), joint bust modal screenshot, POCKET IT → truth-log incident +
|
||||
sting queued. **Tests: 651. Gate clean. Deployed, live 200.**
|
||||
**Broke / known-wonky:**
|
||||
- The joint gates on activity === 'smoking' (a committed yard dwell) — right
|
||||
for real play; test harnesses must pin dwellMs.
|
||||
- Rack carry doesn't slow the player (wobble IS the cost); revisit if the run
|
||||
feels free once John plays it.
|
||||
- ChoiceOverlay is scroll-fixed full-screen; on-floor camera position is
|
||||
irrelevant to it (deliberate).
|
||||
**Next:** SCENARIOS.md 🔜 items (ID passback, regular step-up lines,
|
||||
smoking-door slip-in); the lockout law wants its own design pass; John's two
|
||||
human passes still open.
|
||||
|
||||
79
docs/SCENARIOS.md
Normal file
79
docs/SCENARIOS.md
Normal file
@ -0,0 +1,79 @@
|
||||
# NOT TONIGHT — Character, Scenario & Interaction Backlog
|
||||
|
||||
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
|
||||
game never grades them, and the humane option is never secretly optimal.
|
||||
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
||||
|
||||
## Interactions & mini-systems
|
||||
|
||||
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
|
||||
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
|
||||
log, smoker's night dampened), **POCKET IT** (the game's first corruption
|
||||
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
|
||||
incident log waiting for the report form, where your account of it can be a
|
||||
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
|
||||
up… and it's still on the record as ignored). 'joint' is real contraband, so
|
||||
it also turns up in pat-downs at the rope.
|
||||
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
|
||||
task). A wire rack of glasses appears at the bar's east end; carry it the
|
||||
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
|
||||
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
|
||||
survives for vibe. Racks keep coming. The door is unattended while you do it
|
||||
— Kayden is thrilled.
|
||||
- 🔜 **ID passback detection**: the same card twice in one night — the name on
|
||||
the guest-list clipboard you already checked; second appearance of an
|
||||
identical card = passback. Needs a "cards seen tonight" memory at the desk.
|
||||
- 🔜 **Regular step-up lines** keyed off timesDeniedBefore ("back again. new
|
||||
moustache. no hard feelings").
|
||||
- 🔜 **Smoking-door slip-in** (FLOOR2 §2's second noStamp source).
|
||||
- 💡 **The 1:30 lockout** (deeply Queensland): after 1:30 AM, admissions are
|
||||
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
|
||||
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
|
||||
in one law.
|
||||
- 💡 **Vomit protocol**: someone redecorates the dance floor → deploy the wet
|
||||
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
|
||||
- 💡 **Phone confiscation**: someone filming on the dance floor — the
|
||||
influencer's revenge arc.
|
||||
- 💡 **Water station duty** (RSA): press a cup on a `loose` patron; done right
|
||||
it de-escalates the ripen timer. The humane tool that costs time.
|
||||
- 💡 **First aid**: a fainter. Recovery position via a QTE that is deliberately
|
||||
NOT funny; the room hushes; the night resumes changed.
|
||||
- 💡 **Kebab run**: Dazza demands a kebab mid-slam. The shop is right there.
|
||||
The queue can see you. The whole game in one errand.
|
||||
|
||||
## Characters (door archetypes & scripted people)
|
||||
|
||||
- 💡 **The off-duty cop** — flashes a badge at the rope. Real cop (deny freely,
|
||||
he respects it; admit and he quietly reports the venue's breaches — a
|
||||
walking inspector) or a $30 costume badge (the tells: hat, confidence,
|
||||
wrong lanyard). The ID game's scariest bluff.
|
||||
- 💡 **The celebrity incognito** — cap + sunnies (two live rule breaches!).
|
||||
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
|
||||
rocket OR pap swarm outside (aggro storm). You cannot know which.
|
||||
- 💡 **The twins** — two people, one face, different names. Not a fake:
|
||||
genuinely twins. Everything about the photo check is suddenly on trial.
|
||||
- 💡 **The sovereign citizen** — "I don't recognise your dress code as having
|
||||
authority over my person." Aggro engine in a polo. His ID is a laminated
|
||||
business card.
|
||||
- 💡 **The delivery rider** — insists the DJ ordered food. The DJ did not
|
||||
order food. OR DID HE. (The booth lamp flickers hungrily.)
|
||||
- 💡 **The promoter** — arrives with a roll of wristbands and 'his' guest list
|
||||
that doesn't match yours. Two clipboards enter, one leaves.
|
||||
- 💡 **The noise-complaint neighbour** — pyjamas, at the rope, at 1 AM, not
|
||||
trying to get in: trying to get it turned DOWN. The only patron who wants
|
||||
less club. Handle him badly and the inspector hears about it.
|
||||
- 💡 **The hen's party, lost** — seven matching sashes, wrong venue, right
|
||||
energy. Admitting them is a vibe grenade with a random timer.
|
||||
- 💡 **The ex-doorman** — watches you work a patron, then tells you what you
|
||||
missed (a live tutorial character; his tips are right ~80% of the time,
|
||||
which is the joke).
|
||||
|
||||
## Systems worth a whole phase
|
||||
|
||||
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
|
||||
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
|
||||
mix arc.
|
||||
- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is
|
||||
already externally scriptable — requests at the booth, energy management.
|
||||
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
|
||||
pour-and-cut-off from the other side of the counter.
|
||||
@ -16,7 +16,8 @@ export type SfxName =
|
||||
| 'phoneBuzz'
|
||||
| 'ropeUnhook'
|
||||
| 'denyCrowdOoh'
|
||||
| 'typeTick';
|
||||
| 'typeTick'
|
||||
| 'glassSmash';
|
||||
|
||||
/** Seconds of white noise generated once and shared by every noise voice. */
|
||||
const NOISE_SECONDS = 2;
|
||||
@ -84,6 +85,8 @@ export class Sfx {
|
||||
return this.denyCrowdOoh();
|
||||
case 'typeTick':
|
||||
return this.typeTick();
|
||||
case 'glassSmash':
|
||||
return this.glassSmash();
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,6 +274,15 @@ export class Sfx {
|
||||
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
|
||||
}
|
||||
|
||||
/** A pint glass meeting a tiled floor: bright burst, short shard ring. */
|
||||
glassSmash(): void {
|
||||
const t = this.ctx.currentTime;
|
||||
const src = this.noiseSource();
|
||||
this.voice(src, [this.band(3100, 1.1), this.env(t, 0.001, 0.02, 0.12, 0.22)], t, 0.24);
|
||||
const ring = this.noiseSource();
|
||||
this.voice(ring, [this.band(5200, 6), this.env(t + 0.03, 0.001, 0.01, 0.08, 0.16)], t + 0.03, 0.18);
|
||||
}
|
||||
|
||||
/** Idempotent — the door scene may call this on every re-entry. */
|
||||
startRain(): void {
|
||||
if (this.rain) return;
|
||||
|
||||
@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [
|
||||
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
|
||||
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
|
||||
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
|
||||
{ id: 'joint', name: 'One (1) joint', severity: 2, weight: 2, spriteHint: 'small white cone' },
|
||||
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
|
||||
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
|
||||
];
|
||||
|
||||
@ -126,3 +126,25 @@ export const NO_STAMP_RADIO: readonly string[] = [
|
||||
|
||||
export const HELP_TEXT =
|
||||
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
||||
|
||||
|
||||
/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */
|
||||
export const JOINT_UI = {
|
||||
title: 'SOMETHING SMELLS HERBAL',
|
||||
smokerLine: 'its uh. a rollie. bush tobacco. very natural',
|
||||
bin: 'BIN IT',
|
||||
binDetail: 'by the book. into the amnesty bin, into the log',
|
||||
pocket: 'POCKET IT',
|
||||
pocketDetail: 'for later. for evidence. for whatever',
|
||||
slide: "DIDN'T SEE IT",
|
||||
slideDetail: 'you were never here. neither was the smell',
|
||||
} as const;
|
||||
|
||||
export const JOINT_RESULTS = {
|
||||
binned: 'It goes in the bin. The yard watches it go.',
|
||||
pocketed: 'It goes in your pocket. Nobody says anything. Especially you.',
|
||||
slid: 'You study the fence line. Excellent fence. The yard exhales.',
|
||||
} as const;
|
||||
|
||||
/** Dazza, later, if word gets around about what is in your pocket. */
|
||||
export const JOINT_STING = 'a punter says SECURITY is holding. tell me thats a lie. tell me NOW';
|
||||
|
||||
@ -68,6 +68,9 @@ export const REPORT_TUNING = {
|
||||
// band, never a fixed running order.
|
||||
interest: {
|
||||
deferred: 10, // a breach the audit already found — the awkward one
|
||||
jointPocketed: 9, // the one question you really don't want asked
|
||||
jointBinned: 3,
|
||||
jointIgnored: 5,
|
||||
ejection: 8,
|
||||
maggotUnhandled: 7,
|
||||
kayden: 6, // his mistake, your signature
|
||||
@ -137,6 +140,30 @@ const BANKS: Record<string, AccountBank> = {
|
||||
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointPocketed: {
|
||||
prompt: 'Contraband was located in the smoking area. Where is it now?',
|
||||
options: [
|
||||
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
|
||||
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
|
||||
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointBinned: {
|
||||
prompt: 'Contraband was located in the smoking area. Your account?',
|
||||
options: [
|
||||
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
|
||||
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
|
||||
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointIgnored: {
|
||||
prompt: 'A smell was reported near the smoking area. Your account?',
|
||||
options: [
|
||||
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
|
||||
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
|
||||
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
|
||||
],
|
||||
},
|
||||
kayden: {
|
||||
prompt: 'A ruling was made at the rope while you were inside. Your account?',
|
||||
options: [
|
||||
|
||||
@ -20,7 +20,10 @@ import { layoutPockets, type Pocket } from './patDown';
|
||||
import { CutOffOverlay } from './overlays/CutOffOverlay';
|
||||
import { StallOverlay } from './overlays/StallOverlay';
|
||||
import { PatDownOverlay } from './overlays/PatDownOverlay';
|
||||
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor';
|
||||
import { ChoiceOverlay } from './overlays/ChoiceOverlay';
|
||||
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
|
||||
import { tileToWorld } from './venueMap';
|
||||
import { HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO } from '../../data/strings/floor';
|
||||
import { MeterHud } from '../../ui/MeterHud';
|
||||
import type { NightContext } from '../door/NightScene';
|
||||
import type { Patron } from '../../data/types';
|
||||
@ -31,13 +34,19 @@ const MAX_CROWD = 44;
|
||||
const SPAWN_EVERY_MS = 420;
|
||||
/** Admitted at the rope → appears on the floor after the walk in. */
|
||||
const WALK_IN_MS: [number, number] = [6000, 14000];
|
||||
/** Where a full rack of empties appears (bar's east end) and where it goes. */
|
||||
const RACK_SPOT = tileToWorld(51, 7);
|
||||
const DISH_SPOT = tileToWorld(17, 3);
|
||||
/** Crowd contact distance while carrying, and its debounce. */
|
||||
const CARRY_BUMP_PX = 14;
|
||||
const CARRY_BUMP_COOLDOWN_MS = 350;
|
||||
|
||||
const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
|
||||
sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
|
||||
};
|
||||
|
||||
/** What pressing E on the current target would do. */
|
||||
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';
|
||||
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher';
|
||||
|
||||
interface InteractTarget {
|
||||
kind: InteractKind;
|
||||
@ -77,6 +86,14 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
private cutOff!: CutOffOverlay;
|
||||
private stall!: StallOverlay;
|
||||
private patDown!: PatDownOverlay;
|
||||
private choice!: ChoiceOverlay;
|
||||
private readonly jointDealt = new Set<string>();
|
||||
private rack: RackState | null = null;
|
||||
private rackReadyAtMin = 6;
|
||||
private rackMarker: Phaser.GameObjects.Image | null = null;
|
||||
private carryBumpMs = 0;
|
||||
private prevVx = 0;
|
||||
private prevVy = 0;
|
||||
|
||||
private keys!: Record<string, Phaser.Input.Keyboard.Key>;
|
||||
private uvMode = false;
|
||||
@ -134,6 +151,15 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.view = new FloorView(this, VENUE);
|
||||
this.cutOff = new CutOffOverlay(this);
|
||||
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
||||
this.choice = new ChoiceOverlay(this);
|
||||
this.rackMarker = this.add
|
||||
.image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow')
|
||||
.setDepth(17)
|
||||
.setBlendMode(Phaser.BlendModes.ADD)
|
||||
.setTint(0xd8c23a)
|
||||
.setAlpha(0.5)
|
||||
.setScale(0.8)
|
||||
.setVisible(false);
|
||||
|
||||
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
|
||||
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
|
||||
@ -185,6 +211,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.maggotSeen.clear();
|
||||
this.spotted.clear();
|
||||
this.cutOffDealt.clear();
|
||||
this.jointDealt.clear();
|
||||
this.rack = null;
|
||||
this.rackReadyAtMin = 6;
|
||||
|
||||
this.ctxOffs = [
|
||||
// The floor is fed by the door: every admission walks in a few seconds later.
|
||||
@ -251,6 +280,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.maggotSeen.clear();
|
||||
this.spotted.clear();
|
||||
this.cutOffDealt.clear();
|
||||
this.jointDealt.clear();
|
||||
this.rack = null;
|
||||
this.rackReadyAtMin = 6;
|
||||
|
||||
this.clock.start();
|
||||
this.beat.start();
|
||||
@ -264,6 +296,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
|
||||
if (this.stall.isOpen) return this.stall.handleKey(key);
|
||||
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
||||
if (this.choice.isOpen) return this.choice.handleKey(key);
|
||||
|
||||
if (key === 'TAB') {
|
||||
ev.preventDefault();
|
||||
@ -275,7 +308,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private get overlayOpen(): boolean {
|
||||
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen;
|
||||
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen;
|
||||
}
|
||||
|
||||
private readInput(): PlayerInput {
|
||||
@ -373,6 +406,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
this.crowd.update(dt, this.clock.clockMin);
|
||||
if (this.escorting) this.tickEscort(dt);
|
||||
this.tickCarry(dt);
|
||||
this.rackMarker?.setVisible(this.rack === null && this.clock.clockMin >= this.rackReadyAtMin);
|
||||
|
||||
this.trackMaggots();
|
||||
this.tickCalm(dt);
|
||||
@ -470,6 +505,22 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
private findTarget(): InteractTarget | null {
|
||||
const spec = this.coneSpec();
|
||||
|
||||
// Hands full: the only thing in the world is the dishwasher. Everything
|
||||
// else (including the door home) waits until the rack is down.
|
||||
if (this.rack) {
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
|
||||
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// A full rack waiting at the bar's east end.
|
||||
if (this.clock.clockMin >= this.rackReadyAtMin) {
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
|
||||
if (d <= INTERACT_RANGE) {
|
||||
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
|
||||
}
|
||||
}
|
||||
|
||||
// Night mode: the way back out to the rope is itself an interaction.
|
||||
if (this.nightCtx) {
|
||||
const entry = VENUE.anchors.entry[0];
|
||||
@ -513,6 +564,16 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' };
|
||||
}
|
||||
|
||||
// The yard bust: a smoker whose rollie is not a rollie (docs/SCENARIOS.md).
|
||||
if (
|
||||
best.activity === 'smoking' &&
|
||||
best.patron.flags.contraband?.includes('joint') &&
|
||||
!this.jointDealt.has(best.patron.id)
|
||||
) {
|
||||
this.spot(best.patron.id, 'contraband');
|
||||
return { kind: 'joint', agent: best, prompt: 'E — something smells herbal' };
|
||||
}
|
||||
|
||||
const stage = drunkStage(best.patron.intoxication);
|
||||
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
|
||||
// Dealing with someone settles that stage, not the whole night. The Quiet
|
||||
@ -536,13 +597,120 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (!t || this.escorting) return;
|
||||
|
||||
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
||||
if (t.kind === 'rack') return this.takeRack();
|
||||
if (t.kind === 'dishwasher') return this.deliverRack();
|
||||
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
|
||||
if (!t.agent) return;
|
||||
if (t.kind === 'joint') return this.openJointBust(t.agent);
|
||||
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
|
||||
if (t.kind === 'drunk') return this.openCutOff(t.agent);
|
||||
if (t.kind === 'contraband') return this.openPatDown(t.agent);
|
||||
}
|
||||
|
||||
// ---- the joint bust (docs/SCENARIOS.md) --------------------------------
|
||||
|
||||
private openJointBust(agent: Agent): void {
|
||||
this.choice.open(
|
||||
{
|
||||
title: JOINT_UI.title,
|
||||
line: JOINT_UI.smokerLine,
|
||||
patron: agent.patron,
|
||||
choices: [
|
||||
{ id: 'bin', label: JOINT_UI.bin, detail: JOINT_UI.binDetail },
|
||||
{ id: 'pocket', label: JOINT_UI.pocket, detail: JOINT_UI.pocketDetail },
|
||||
{ id: 'slide', label: JOINT_UI.slide, detail: JOINT_UI.slideDetail },
|
||||
],
|
||||
},
|
||||
(id) => {
|
||||
if (id === null) return; // walked away; the smell keeps
|
||||
this.jointDealt.add(agent.patron.id);
|
||||
const left = (agent.patron.flags.contraband ?? []).filter((c) => c !== 'joint');
|
||||
if (left.length > 0) agent.patron.flags.contraband = left;
|
||||
else delete agent.patron.flags.contraband;
|
||||
|
||||
if (id === 'bin') {
|
||||
// By the book. The yard notices the book.
|
||||
this.bus.emit('meters:delta', { aggro: 1 });
|
||||
this.logIncident('jointBinned', agent.patron.id, 'Confiscated in the yard, into the amnesty bin.');
|
||||
this.toast(JOINT_RESULTS.binned);
|
||||
} else if (id === 'pocket') {
|
||||
// Nothing happens now. That is the whole point of now.
|
||||
this.logIncident('jointPocketed', agent.patron.id, 'Confiscated in the yard. Retained by security.');
|
||||
this.toast(JOINT_RESULTS.pocketed);
|
||||
if (this.nightCtx && this.rng.stream('joint').chance(0.5)) {
|
||||
this.nightCtx.scheduleDeferred([{
|
||||
atClockMin: this.clock.clockMin + 20 + this.rng.stream('joint').int(0, 40),
|
||||
vibeDelta: -2,
|
||||
dazzaText: JOINT_STING,
|
||||
reason: 'word got around about what security is holding',
|
||||
patronId: agent.patron.id,
|
||||
}]);
|
||||
}
|
||||
} else {
|
||||
// The fence line really is excellent tonight.
|
||||
agent.patron.intoxication = Math.min(1, agent.patron.intoxication + 0.08);
|
||||
this.bus.emit('meters:delta', { vibe: 1 });
|
||||
this.logIncident('jointIgnored', agent.patron.id, 'A smell near the yard. No action taken.');
|
||||
this.toast(JOINT_RESULTS.slid);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the glassie run (docs/SCENARIOS.md) -------------------------------
|
||||
|
||||
private takeRack(): void {
|
||||
this.rack = freshRack();
|
||||
this.rackMarker?.setVisible(false);
|
||||
this.prevVx = this.player.vx;
|
||||
this.prevVy = this.player.vy;
|
||||
}
|
||||
|
||||
private deliverRack(): void {
|
||||
const r = this.rack;
|
||||
if (!r) return;
|
||||
this.rack = null;
|
||||
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
|
||||
const vibe = Math.min(3, r.glasses * 0.5);
|
||||
if (vibe > 0) this.bus.emit('meters:delta', { vibe });
|
||||
this.logIncident('glassieRun', undefined, `${r.glasses}/6 glasses made it to the dishwasher.`);
|
||||
this.toast(r.glasses === 6 ? 'Full rack, not a chip. The dishwasher accepts your offering.' : `${r.glasses} of 6 survived the crowd.`);
|
||||
}
|
||||
|
||||
/** Carry physics: bumps and hard steering shake the rack; wobble breaks glass. */
|
||||
private tickCarry(dt: number): void {
|
||||
const r = this.rack;
|
||||
if (!r) return;
|
||||
this.carryBumpMs += dt;
|
||||
let bumped = false;
|
||||
if (this.carryBumpMs >= CARRY_BUMP_COOLDOWN_MS) {
|
||||
for (const a of this.crowd.agents) {
|
||||
if (a.gone) continue;
|
||||
if (Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y) < CARRY_BUMP_PX) {
|
||||
bumped = true;
|
||||
this.carryBumpMs = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sharp = turnSharpness(this.prevVx, this.prevVy, this.player.vx, this.player.vy, 116);
|
||||
this.prevVx = this.player.vx;
|
||||
this.prevVy = this.player.vy;
|
||||
const out = stepRack(r, dt, bumped, sharp);
|
||||
this.rack = out.rack;
|
||||
if (out.broke > 0) {
|
||||
this.nightCtx?.sfx?.play('glassSmash');
|
||||
this.bus.emit('meters:delta', { vibe: -0.5 });
|
||||
if (out.rack.glasses <= 0) {
|
||||
this.rack = null;
|
||||
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
|
||||
this.bus.emit('meters:delta', { vibe: -1.5 });
|
||||
this.logIncident('rackDropped', undefined, 'The whole rack. The whole floor heard it.');
|
||||
this.toast('The last glass goes. The crowd applauds. Someone yells TAXI.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private openStall(stallId: string): void {
|
||||
const occupants = this.crowd.stallOccupancy().get(stallId) ?? [];
|
||||
this.stall.open(
|
||||
@ -655,12 +823,17 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (this.radioMs <= 0) this.radioText.setText('');
|
||||
}
|
||||
// A brewing fight outranks everything — it's the one thing on a clock.
|
||||
const carryLine = this.rack
|
||||
? `RACK ${'▮'.repeat(this.rack.glasses)}${'▯'.repeat(6 - this.rack.glasses)} wobble ${'!'.repeat(Math.min(4, Math.ceil(this.rack.wobble * 4)))}`
|
||||
: null;
|
||||
this.promptText.setText(
|
||||
this.fights.active
|
||||
? 'GET BETWEEN THEM'
|
||||
: this.escorting
|
||||
? 'escorting — walk them to the door (no interacting)'
|
||||
: (this.target?.prompt ?? ''),
|
||||
: carryLine && !this.target
|
||||
? carryLine
|
||||
: this.target?.prompt ?? '',
|
||||
);
|
||||
const maggots = this.crowd.agents.filter(
|
||||
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
|
||||
@ -679,6 +852,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.cutOff?.destroy();
|
||||
this.stall?.destroy();
|
||||
this.patDown?.destroy();
|
||||
this.choice?.destroy();
|
||||
// Night mode: the bus/meters belong to NightScene — touch nothing shared.
|
||||
this.meters?.destroy();
|
||||
this.hud?.destroy();
|
||||
|
||||
@ -619,6 +619,7 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
|
||||
poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
|
||||
cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
|
||||
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
|
||||
dishwasher: [32, 16],
|
||||
};
|
||||
|
||||
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
||||
@ -724,6 +725,9 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
||||
case 'wetFloor':
|
||||
f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c');
|
||||
break;
|
||||
case 'dishwasher':
|
||||
f(1, 1, 30, 14, '#3a4450'); f(4, 4, 10, 8, '#22303a'); f(17, 4, 11, 3, '#50c060'); f(17, 9, 11, 3, '#8a9aa2');
|
||||
break;
|
||||
case 'poolTable':
|
||||
f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c');
|
||||
for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) {
|
||||
|
||||
63
src/scenes/floor/glassie.ts
Normal file
63
src/scenes/floor/glassie.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// The glassie run, pure half (docs/SCENARIOS.md): carry a wire rack of glasses
|
||||
// through a nightclub crowd without redecorating the floor. Wobble physics and
|
||||
// breakage rules live here so the feel is testable; the scene only reports
|
||||
// bumps and steering.
|
||||
|
||||
export const RACK_GLASSES = 6;
|
||||
/** Wobble at or past this breaks a glass (and the wobble sheds with it). */
|
||||
export const BREAK_AT = 1;
|
||||
/** A break dumps you back here — shaken, not reset. */
|
||||
const AFTER_BREAK = 0.45;
|
||||
/** Passive settle per second when you walk smoothly. */
|
||||
const SETTLE_PER_S = 0.35;
|
||||
/** One crowd bump is most of the way to trouble on a settled rack. */
|
||||
const BUMP_WOBBLE = 0.55;
|
||||
/** Full-lock steering reversal adds this much per second. */
|
||||
const TURN_WOBBLE_PER_S = 1.4;
|
||||
/** In-game minutes between racks — the bar never stops producing empties. */
|
||||
export const RACK_RESPAWN_MIN = 22;
|
||||
|
||||
export interface RackState {
|
||||
glasses: number;
|
||||
wobble: number;
|
||||
}
|
||||
|
||||
export const freshRack = (): RackState => ({ glasses: RACK_GLASSES, wobble: 0 });
|
||||
|
||||
/**
|
||||
* Advance the rack one frame. `turnSharpness` is 0..1 (how hard the carry
|
||||
* velocity swung this frame); `bumped` is a de-bounced crowd contact.
|
||||
* Returns the new state plus how many glasses broke THIS frame (0 or 1 — a
|
||||
* frame only ever breaks one; the wobble shed makes chains possible but earned).
|
||||
*/
|
||||
export function stepRack(
|
||||
rack: RackState,
|
||||
dtMs: number,
|
||||
bumped: boolean,
|
||||
turnSharpness: number,
|
||||
): { rack: RackState; broke: number } {
|
||||
const dt = Math.max(0, dtMs) / 1000;
|
||||
let wobble = rack.wobble - SETTLE_PER_S * dt;
|
||||
wobble += Math.max(0, Math.min(1, turnSharpness)) * TURN_WOBBLE_PER_S * dt;
|
||||
if (bumped) wobble += BUMP_WOBBLE;
|
||||
wobble = Math.max(0, wobble);
|
||||
|
||||
if (wobble >= BREAK_AT && rack.glasses > 0) {
|
||||
return { rack: { glasses: rack.glasses - 1, wobble: AFTER_BREAK }, broke: 1 };
|
||||
}
|
||||
return { rack: { glasses: rack.glasses, wobble }, broke: 0 };
|
||||
}
|
||||
|
||||
/** 0..1 steering harshness from consecutive frame velocities. */
|
||||
export function turnSharpness(
|
||||
prevVx: number,
|
||||
prevVy: number,
|
||||
vx: number,
|
||||
vy: number,
|
||||
maxSpeed: number,
|
||||
): number {
|
||||
if (maxSpeed <= 0) return 0;
|
||||
const dx = vx - prevVx;
|
||||
const dy = vy - prevVy;
|
||||
return Math.min(1, Math.hypot(dx, dy) / (maxSpeed * 2));
|
||||
}
|
||||
140
src/scenes/floor/overlays/ChoiceOverlay.ts
Normal file
140
src/scenes/floor/overlays/ChoiceOverlay.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../../patrons/doll';
|
||||
import type { Patron } from '../../../data/types';
|
||||
|
||||
// A reusable three-verb modal for floor scenarios (docs/SCENARIOS.md): the
|
||||
// joint bust today, whatever the yard produces next. Same shell discipline as
|
||||
// the other overlays: the scene routes keys, the overlay owns no listeners of
|
||||
// its own beyond pointer hits on its rows.
|
||||
|
||||
const W = 640;
|
||||
const H = 360;
|
||||
const PANEL_X = 96;
|
||||
const PANEL_Y = 52;
|
||||
const PANEL_W = 448;
|
||||
const PANEL_H = 236;
|
||||
const ROW_X = PANEL_X + 118;
|
||||
const ROW_W = PANEL_W - 136;
|
||||
const ROW_TOP = PANEL_Y + 86;
|
||||
const ROW_GAP = 34;
|
||||
const AMBER = 0xd8a020;
|
||||
|
||||
const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({
|
||||
fontFamily: 'monospace',
|
||||
fontSize: `${size}px`,
|
||||
color: colour,
|
||||
});
|
||||
|
||||
export interface ChoiceDef {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ChoiceArgs {
|
||||
title: string;
|
||||
/** What the person at the centre of it says. */
|
||||
line: string;
|
||||
patron: Patron;
|
||||
choices: readonly ChoiceDef[]; // up to 3
|
||||
}
|
||||
|
||||
export class ChoiceOverlay {
|
||||
private readonly scene: Phaser.Scene;
|
||||
private readonly root: Phaser.GameObjects.Container;
|
||||
private readonly titleText: Phaser.GameObjects.Text;
|
||||
private readonly lineText: Phaser.GameObjects.Text;
|
||||
private readonly doll: Phaser.GameObjects.Image;
|
||||
private readonly rows: Array<{
|
||||
box: Phaser.GameObjects.Rectangle;
|
||||
label: Phaser.GameObjects.Text;
|
||||
detail: Phaser.GameObjects.Text;
|
||||
}> = [];
|
||||
private choices: readonly ChoiceDef[] = [];
|
||||
private done: ((id: string | null) => void) | null = null;
|
||||
private open_ = false;
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene;
|
||||
this.root = scene.add.container(0, 0).setDepth(5000).setScrollFactor(0).setVisible(false);
|
||||
this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82));
|
||||
this.root.add(
|
||||
scene.add.rectangle(W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x101018).setStrokeStyle(1, 0x6080a0, 0.7),
|
||||
);
|
||||
|
||||
this.titleText = scene.add.text(W / 2, PANEL_Y + 16, '', mono(11, '#9fc0e8')).setOrigin(0.5, 0);
|
||||
this.lineText = scene.add
|
||||
.text(PANEL_X + 118, PANEL_Y + 44, '', mono(8, '#cfc6d8'))
|
||||
.setOrigin(0, 0)
|
||||
.setWordWrapWidth(ROW_W);
|
||||
this.doll = scene.add.image(PANEL_X + 58, PANEL_Y + 120, '__DEFAULT').setScale(2);
|
||||
this.root.add([this.titleText, this.lineText, this.doll]);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = ROW_TOP + i * ROW_GAP;
|
||||
const box = scene.add
|
||||
.rectangle(ROW_X + ROW_W / 2, y, ROW_W, 28, 0x14141f)
|
||||
.setStrokeStyle(1, 0x2a2a3a)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const label = scene.add.text(ROW_X + 10, y - 6, '', mono(9, '#e8e0d0')).setOrigin(0, 0.5);
|
||||
const detail = scene.add.text(ROW_X + 10, y + 7, '', mono(7, '#6a7a8a')).setOrigin(0, 0.5);
|
||||
box.on('pointerover', () => box.setStrokeStyle(1, AMBER));
|
||||
box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a));
|
||||
box.on('pointerup', () => this.pick(i));
|
||||
this.root.add([box, label, detail]);
|
||||
this.rows.push({ box, label, detail });
|
||||
}
|
||||
|
||||
this.root.add(
|
||||
scene.add
|
||||
.text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC walk away', mono(7, '#5a5a70'))
|
||||
.setOrigin(0.5, 0),
|
||||
);
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.open_;
|
||||
}
|
||||
|
||||
open(args: ChoiceArgs, done: (id: string | null) => void): void {
|
||||
this.done = done;
|
||||
this.choices = args.choices.slice(0, 3);
|
||||
this.open_ = true;
|
||||
this.titleText.setText(args.title);
|
||||
this.lineText.setText(`"${args.line}"`);
|
||||
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
|
||||
this.rows.forEach((r, i) => {
|
||||
const c = this.choices[i];
|
||||
r.box.setVisible(!!c);
|
||||
r.label.setVisible(!!c).setText(c ? `${i + 1} ${c.label}` : '');
|
||||
r.detail.setVisible(!!c).setText(c ? c.detail : '');
|
||||
});
|
||||
this.root.setVisible(true);
|
||||
}
|
||||
|
||||
handleKey(key: string): void {
|
||||
if (!this.open_) return;
|
||||
if (key === 'ESCAPE' || key === 'ESC') return this.close(null);
|
||||
const idx = Number(key) - 1;
|
||||
if (idx >= 0 && idx < this.choices.length) this.pick(idx);
|
||||
}
|
||||
|
||||
update(_dtMs: number): void {}
|
||||
|
||||
private pick(i: number): void {
|
||||
const c = this.choices[i];
|
||||
if (c) this.close(c.id);
|
||||
}
|
||||
|
||||
private close(id: string | null): void {
|
||||
this.open_ = false;
|
||||
this.root.setVisible(false);
|
||||
const done = this.done;
|
||||
this.done = null;
|
||||
done?.(id);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.destroy();
|
||||
}
|
||||
}
|
||||
@ -114,6 +114,11 @@ function buildTiles(): TileKind[] {
|
||||
// Dunnies get plumbing: sink row along the south interior wall.
|
||||
rect(66, 15, 71, 15, 'sink');
|
||||
|
||||
// The kitchen: back-of-house behind the bar's west end. Glassie territory —
|
||||
// the dishwasher lives here and the racks come home to it.
|
||||
box(14, 1, 21, 8, 'wall');
|
||||
set(18, 8, 'floor');
|
||||
|
||||
// Dunnies: walled room in the north-east, one doorway on its south face.
|
||||
box(60, 3, 76, 16, 'wall');
|
||||
rect(62, 16, 63, 16, 'floor');
|
||||
@ -178,7 +183,7 @@ export type PropKind =
|
||||
| 'speaker' | 'discoball'
|
||||
| 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack'
|
||||
| 'boothTable' | 'highTable' | 'sinkRow'
|
||||
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable'
|
||||
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
|
||||
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
|
||||
|
||||
export interface PropEmissive {
|
||||
@ -229,9 +234,10 @@ export const PROPS: readonly PropDef[] = Object.freeze([
|
||||
P('glassStack', 'glassStack', 24, 4, 2, 1),
|
||||
P('taps', 'taps', 32, 4, 3, 1),
|
||||
P('till', 'till', 46, 4, 2, 1),
|
||||
// glassie station (future role home — design §6)
|
||||
// the kitchen (glassie territory — design §6)
|
||||
P('rackA', 'glassRack', 16, 4, 2, 1),
|
||||
P('rackB', 'glassRack', 16, 6, 2, 1),
|
||||
P('dish', 'dishwasher', 15, 2, 2, 1, { colour: 0x50c060, radius: 20 }),
|
||||
// booths
|
||||
P('tblS1', 'boothTable', 25, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
|
||||
P('tblS2', 'boothTable', 32, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
|
||||
|
||||
53
tests/floor/glassie.test.ts
Normal file
53
tests/floor/glassie.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BREAK_AT, RACK_GLASSES, freshRack, stepRack, turnSharpness } from '../../src/scenes/floor/glassie';
|
||||
|
||||
describe('the glassie run (pure physics)', () => {
|
||||
it('a smooth walk settles the rack and breaks nothing', () => {
|
||||
let r = { glasses: RACK_GLASSES, wobble: 0.6 };
|
||||
let broke = 0;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const out = stepRack(r, 16, false, 0);
|
||||
r = out.rack;
|
||||
broke += out.broke;
|
||||
}
|
||||
expect(broke).toBe(0);
|
||||
expect(r.wobble).toBe(0);
|
||||
expect(r.glasses).toBe(RACK_GLASSES);
|
||||
});
|
||||
|
||||
it('two quick crowd bumps break a glass; the wobble sheds with it', () => {
|
||||
let r = freshRack();
|
||||
r = stepRack(r, 16, true, 0).rack;
|
||||
expect(r.glasses).toBe(RACK_GLASSES); // one bump is a scare, not a smash
|
||||
const out = stepRack(r, 16, true, 0);
|
||||
expect(out.broke).toBe(1);
|
||||
expect(out.rack.glasses).toBe(RACK_GLASSES - 1);
|
||||
expect(out.rack.wobble).toBeLessThan(BREAK_AT);
|
||||
});
|
||||
|
||||
it('sawing the stick hard eventually costs glass even with no crowd', () => {
|
||||
let r = freshRack();
|
||||
let broke = 0;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const out = stepRack(r, 16, false, 1);
|
||||
r = out.rack;
|
||||
broke += out.broke;
|
||||
}
|
||||
expect(broke).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a rack cannot go below zero glasses', () => {
|
||||
const r = { glasses: 1, wobble: 0.99 };
|
||||
const out = stepRack(r, 16, true, 1);
|
||||
expect(out.rack.glasses).toBe(0);
|
||||
const again = stepRack(out.rack, 16, true, 1);
|
||||
expect(again.rack.glasses).toBe(0);
|
||||
expect(again.broke).toBe(0);
|
||||
});
|
||||
|
||||
it('turnSharpness is 0 for steady cruise, 1 for a full reversal', () => {
|
||||
expect(turnSharpness(100, 0, 100, 0, 116)).toBe(0);
|
||||
expect(turnSharpness(116, 0, -116, 0, 116)).toBe(1);
|
||||
expect(turnSharpness(0, 0, 60, 0, 0)).toBe(0); // junk maxSpeed is inert
|
||||
});
|
||||
});
|
||||
@ -26,6 +26,7 @@ TARGETS = {
|
||||
"poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16),
|
||||
"cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32),
|
||||
"highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32),
|
||||
"dishwasher": (32, 16),
|
||||
# the door-scene backdrop plate is special-cased: full size, no quantise
|
||||
"street:backdrop": (640, 176),
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user