diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 612e718..3efd418 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -85,6 +85,29 @@ Attract ragdoll → the pulsing title. Verified: title screenshot, click path, production `npm run build` clean. +## BUILD-OUT PASS 6 ✅ — the audience + the global board +- **The crowd**: 11 seeded silhouette heads (shoulders/head/occasional topknot) + along the stage lip. Idle sway; trembling once anger>0.5; a hard duck for 110 + frames at the string cut (`Stage.flinch`); happy bobbing on an S + (`cheerCrowd()`). Pure deterministic math in update() — harness-safe. +- **Global leaderboards**: express endpoints on the arcade server + (`/api/sando/scores` GET?week=omote|ura / POST {name,score,week}, json-file + storage `sando-scores.json`, top-100 kept) + a new nginx location + **`/arcade-api/` → arcade-landing:3001/api/** in the partly.party server + block. Game client `game/scores.ts` (4s timeout, silent-fail — the game never + notices a dead server). Week score = Σ verdict.total across served days + (registry `sando.score`, reset on week start/toggle); KANSEI submits under a + remembered chef name (window.prompt once — ponytail: that IS the entry UI) + and shows rank + podium; the title shows the top-5 board. +- **Infra learned the hard way** (documented in arcade-landing repo + + `forum-nginx.conf` canonical copy): partly.party is served by the FIRST + server block in `/home/humanjing/forum/nginx/nginx.conf` (a bind-mount into + forum-nginx; `conf.d/` is NOT included — edits there are inert; docker cp + fails busy — edit the host file + `nginx -s reload`). +- E2E proven: POST via https://partly.party/arcade-api/sando/scores → rank 1 → + GET returns it. Battery unchanged; crowd flinches on cut; day-1 serve banks + 7.63 into the week score. + ## BUILD-OUT PASS 5 ✅ — the judge speaks, the band worries, the jelly wobbles - **Live barks**: every sin now earns a subtitled heckle at the foot of the stage (`Stage.bark(group, seed)` → the existing line bank; seed varies the pick so diff --git a/src/game/game.ts b/src/game/game.ts index 5717b63..721f1e6 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -92,6 +92,10 @@ export class Game { this.state = 'served'; this.lastVerdict = this.serve(true); recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+ + // the week's running score, headed for the leaderboard at KANSEI + const run = ((this.stage.registry.get('sando.score') as number) ?? 0) + this.lastVerdict.total; + this.stage.registry.set('sando.score', +run.toFixed(2)); + if (this.lastVerdict.grade === 'S') this.stage.cheerCrowd(); this.stage.showScorecard(this.lastVerdict); } } else { diff --git a/src/game/scores.ts b/src/game/scores.ts new file mode 100644 index 0000000..49d7a3e --- /dev/null +++ b/src/game/scores.ts @@ -0,0 +1,64 @@ +/** + * Global leaderboard client. Same-origin on partly.party via the /arcade-api/ + * nginx proxy → arcade-landing express. Every call is fire-and-forget with a + * short timeout and silent failure — the game must never notice a dead server. + * (In local dev there's no proxy; fetches just fail quietly.) + */ + +export interface ScoreRow { + name: string; + score: number; + date: string; +} + +const API = '/arcade-api/sando/scores'; +const TIMEOUT = 4000; + +function withTimeout(ms: number): AbortSignal { + const c = new AbortController(); + setTimeout(() => c.abort(), ms); + return c.signal; +} + +export async function fetchTop(week: 'omote' | 'ura'): Promise { + try { + const r = await fetch(`${API}?week=${week}`, { signal: withTimeout(TIMEOUT) }); + if (!r.ok) return []; + const rows = (await r.json()) as ScoreRow[]; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +export async function submitScore(name: string, score: number, week: 'omote' | 'ura'): Promise { + try { + const r = await fetch(API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, score, week }), + signal: withTimeout(TIMEOUT), + }); + if (!r.ok) return null; + const j = (await r.json()) as { rank?: number }; + return j.rank ?? null; + } catch { + return null; + } +} + +/** The chef's name, remembered once. ponytail: window.prompt is the whole name + * entry UI — arcade-appropriate; build letter slots if anyone ever complains. */ +export function chefName(): string | null { + try { + let n = localStorage.getItem('sandoniette.name'); + if (!n) { + n = (window.prompt('Your chef name for the board? (3-12 letters)') ?? '').trim().slice(0, 12); + if (n.length < 3) return null; + localStorage.setItem('sandoniette.name', n); + } + return n; + } catch { + return null; + } +} diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 72c1ccb..f774251 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -9,6 +9,7 @@ import type { Group, Verdict } from '../game/judging'; import { barkFor } from '../game/lines'; import { sfx, music } from '../game/audio'; import { loadProgress, unlockUra } from '../game/progress'; +import { chefName, fetchTop, submitScore } from '../game/scores'; const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury']; @@ -58,6 +59,10 @@ export class Stage extends Phaser.Scene { private barkText?: Phaser.GameObjects.Text; private barkSub?: Phaser.GameObjects.Text; private wobbleParts = new Map(); + private crowd: { c: Phaser.GameObjects.Container; x: number; ph: number; sz: number }[] = []; + private crowdT = 0; + private flinch = 0; // frames of post-slash cowering left + private cheer = 0; // frames of S-grade bobbing left constructor() { super('stage'); @@ -148,6 +153,23 @@ export class Stage extends Phaser.Scene { this.barkSub = this.add.text(STAGE_W / 2, 696, '', { fontFamily: 'serif', fontSize: '13px', color: '#9a8a6a', fontStyle: 'italic' }) .setOrigin(0.5).setDepth(8).setAlpha(0); + // the audience — a row of little silhouette heads at the foot of the stage, + // watching from the dark like any good shadow theatre. They sway, they + // flinch when the blade comes out, they bob for an S. Seeded layout so the + // same crowd shows up every night. + this.crowd = []; + let cs = 77; + const crnd = () => ((cs = (cs * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); + for (let i = 0; i < 11; i++) { + const x = 40 + i * (STAGE_W - 80) / 10 + (crnd() - 0.5) * 40; + const sz = 0.75 + crnd() * 0.5; + const c = this.add.container(x, STAGE_H + 6).setDepth(9); + c.add(this.add.ellipse(0, 0, 64 * sz, 46 * sz, 0x060403)); // shoulders + c.add(this.add.circle(0, -26 * sz, 17 * sz, 0x060403)); // head + if (crnd() < 0.35) c.add(this.add.rectangle(12 * sz, -40 * sz, 8 * sz, 14 * sz, 0x060403).setRotation(0.3)); // topknot + this.crowd.push({ c, x, ph: crnd() * Math.PI * 2, sz }); + } + // ambient weather for the hour: dust motes at noon, petals at dusk, // fireflies by night. A 5px generated dot is the whole particle budget. if (!this.textures.exists('mote')) { @@ -194,13 +216,9 @@ export class Stage extends Phaser.Scene { // game for real (SHIN-KANSEI), then it's back to a quiet noon kitchen. this.input.on('pointerdown', () => { if (this.kansei) { - if (this.session.ura) { - this.registry.set('sando.ura', false); - this.registry.set('sando.day', 0); - } else { - this.registry.set('sando.ura', true); - this.registry.set('sando.day', 0); - } + this.registry.set('sando.ura', !this.session.ura); + this.registry.set('sando.day', 0); + this.registry.set('sando.score', 0); // fresh week, fresh run this.scene.restart(); } else if (this.session.state === 'served') { if (this.session.day + 1 >= this.session.days.length) this.showKansei(); @@ -252,6 +270,22 @@ export class Stage extends Phaser.Scene { fontFamily: 'serif', fontSize: '16px', color: ura ? '#9a8a6a' : '#e04a30', }).setOrigin(0.5), ); + // the week's score → the global board (fire-and-forget; card updates when + // the server answers, and shows nothing if it never does) + const weekScore = (this.registry.get('sando.score') as number) ?? 0; + const week = ura ? 'ura' : 'omote'; + g.add(this.add.text(cx, 305, `week score ${weekScore.toFixed(1)} / 80`, { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5)); + const rankLine = this.add.text(cx, 328, '', { fontFamily: 'monospace', fontSize: '13px', color: '#9a8a6a' }).setOrigin(0.5); + g.add(rankLine); + const name = weekScore > 0 ? chefName() : null; + if (name) { + void submitScore(name, weekScore, week).then(async (rank) => { + const top = await fetchTop(week); + if (!g.active) return; // card already dismissed + const podium = top.slice(0, 3).map((r, i) => `${i + 1}. ${r.name} ${r.score.toFixed(1)}`).join(' '); + rankLine.setText(`${rank ? `board rank #${rank}` : ''}${podium ? ` · ${podium}` : ''}`); + }); + } this.scorecard = g; // rides the same clear path } @@ -313,10 +347,42 @@ export class Stage extends Phaser.Scene { } this.slashFx(); music.stop(); // the band stops when the blade comes out + this.flinch = 110; // the audience ducks sfx.slash(); sfx.gong(); } + /** An S-grade serve gets the little heads bobbing. */ + cheerCrowd(): void { + this.cheer = 240; + } + + /** The audience breathes every frame: idle sway, unease as the meter climbs, + * a hard duck at the slash, happy bobbing for an S. Pure deterministic math + * driven from update() so it works under the harness too. */ + private updateCrowd(): void { + this.crowdT++; + const anger = this.session.samurai.anger; + if (this.flinch > 0) this.flinch--; + if (this.cheer > 0) this.cheer--; + for (let i = 0; i < this.crowd.length; i++) { + const m = this.crowd[i]; + const sway = Math.sin(this.crowdT * 0.02 + m.ph) * 3; + const tremble = anger > 0.5 ? Math.sin(this.crowdT * 0.55 + m.ph * 3) * anger * 2.2 : 0; + let y = STAGE_H + 6; + let x = m.x + sway + tremble; + if (this.flinch > 0) { + // duck below the stage lip, then creep back up + const t = Math.min(1, this.flinch / 40); + y += 34 * t * m.sz; + x = m.x + Math.sin(this.crowdT * 0.8 + m.ph) * 1.2; + } else if (this.cheer > 0) { + y -= Math.abs(Math.sin(this.crowdT * 0.25 + m.ph)) * 10; + } + m.c.setPosition(x, y); + } + } + private slashFx(): void { const line = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W * 1.5, 7, 0xffffff).setDepth(20).setRotation(-0.5); this.tweens.add({ targets: line, alpha: 0, duration: 450, onComplete: () => line.destroy() }); @@ -568,6 +634,7 @@ export class Stage extends Phaser.Scene { } this.drawStrings(); this.drawServe(); + this.updateCrowd(); this.refreshOrder(); this.updateHUD(); } diff --git a/src/scenes/title.ts b/src/scenes/title.ts index 3c59bda..d32429b 100644 --- a/src/scenes/title.ts +++ b/src/scenes/title.ts @@ -3,6 +3,7 @@ import { sfx, music } from '../game/audio'; import { STRING_STYLES, type StringStyle } from '../sim/marionette'; import { loadProgress } from '../game/progress'; import { DAYS } from '../game/game'; +import { fetchTop } from '../game/scores'; /** * Title card, launched as an overlay over the live stage so the dev harness is @@ -70,6 +71,7 @@ export class Title extends Phaser.Scene { const next = !((this.registry.get('sando.ura') as boolean) ?? false); this.registry.set('sando.ura', next); this.registry.set('sando.day', 0); + this.registry.set('sando.score', 0); sfx.enable(); sfx.shamisen(); paintWeek(next); @@ -104,6 +106,14 @@ export class Title extends Phaser.Scene { }); } + // the global board, bottom-left — top 5 of the week (async; blank offline) + const board = this.add.text(28, H - 150, '', { fontFamily: 'monospace', fontSize: '12px', color: '#8a7a5a', lineSpacing: 5 }) + .setDepth(2); + void fetchTop(((this.registry.get('sando.ura') as boolean) ?? false) ? 'ura' : 'omote').then((top) => { + if (!board.active || !top.length) return; + board.setText([' TOP CHEFS', ...top.slice(0, 5).map((r, i) => `${i + 1}. ${r.name.padEnd(13)} ${r.score.toFixed(1)}`)].join('\n')); + }); + // begin: anywhere that isn't a style button. Restart the stage so the puppet // is strung with the chosen style, then drop the overlay. this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => {