Build-out 6: the audience + global leaderboards
- The crowd: 11 seeded silhouette heads along the stage lip — idle sway, trembling past anger 0.5, a hard 110-frame duck at the string cut, happy bobbing on an S. Deterministic math in update(), harness-safe. - Global boards: game/scores.ts client (silent-fail, 4s timeout) against the arcade server's new /api/sando/scores (via the new partly.party /arcade-api/ nginx proxy). Week score = sum of verdict totals across served days; KANSEI submits under a remembered chef name and shows rank + podium; the title shows top-5. - E2E proven through https://partly.party/arcade-api/sando/scores (rank 1 round-trip). Battery unchanged; crowd flinch + score accumulation verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
21dedad96a
commit
820b559556
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
64
src/game/scores.ts
Normal file
64
src/game/scores.ts
Normal file
@ -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<ScoreRow[]> {
|
||||
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<number | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<MatterJS.BodyType, { bodies: MatterJS.BodyType[]; cons: MatterJS.ConstraintType[] }>();
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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[]) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user