Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/game/src/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
armorClassInfo,
type ArmoryItemId,
type Loadout,
type SideId,
} from "./loadout";
import type { Briefing, CoachState } from "./onboarding";

/**
* HUD and start gate.
Expand All @@ -44,6 +46,14 @@ export interface HudOptions {
/** Credits on hand, and the purchase sink. Returns whether the sale went through. */
credits?: number;
onBuy?: (item: ArmoryItemId) => boolean;
/**
* First-run briefing for the chosen side, shown at the top of the gate until
* dismissed. Absent once the player has been onboarded.
*/
briefing?: (side: SideId) => Briefing;
onBriefingDismiss?: () => void;
/** Skip the in-yard coach. Offered on the gate while the coach is running. */
onSkipCoach?: () => void;
readonly renderer: string;
readonly mapName: string;
readonly mapChecksum: string;
Expand All @@ -64,6 +74,8 @@ export interface Hud {
setLocked(locked: boolean): void;
/** Credits on hand; re-enables and disables the armory's buttons. */
setCredits(credits: number): void;
/** The coach's current step, or null to hide it. */
setCoach(state: CoachState | null): void;
dispose(): void;
}

Expand Down Expand Up @@ -196,6 +208,20 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
const notices = el("div", "notices");
hud.append(notices);

// First-run coach: one control at a time, above the reticle. A live region,
// so a screen reader announces each new step (accessibility is P0).
const coach = el("div", "coach");
coach.hidden = true;
coach.setAttribute("role", "status");
coach.setAttribute("aria-live", "polite");
const coachCount = el("p", "coach__count");
const coachLine = el("p", "coach__line");
const coachKeys = el("kbd", "coach__keys");
const coachAction = el("span", "coach__action");
coachLine.append(coachKeys, coachAction);
coach.append(coachCount, coachLine, el("p", "coach__skip", "Esc, then Skip training"));
hud.append(coach);

// Blood and hit-direction arcs are created per hit and remove themselves.
const spatter = el("div", "spatter");
hud.append(spatter);
Expand Down Expand Up @@ -239,6 +265,32 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
),
);

// First-run briefing. Who you are and what this place is, before the options.
const briefing = el("section", "briefing");
briefing.setAttribute("aria-labelledby", "nc7-briefing-title");
const renderBriefing = (side: SideId) => {
if (!options.briefing) {
briefing.hidden = true;
return;
}
const content = options.briefing(side);
const title = el("h2", "briefing__title", content.title);
title.id = "nc7-briefing-title";
const dismiss = el("button", "briefing__dismiss", "Got it");
dismiss.type = "button";
dismiss.addEventListener("click", () => {
briefing.hidden = true;
options.onBriefingDismiss?.();
});
briefing.replaceChildren(
title,
...content.lines.map((line) => el("p", "briefing__line", line)),
dismiss,
);
};
renderBriefing((options.loadout ?? DEFAULT_LOADOUT).side);
gate.append(briefing);

// Mode picker.
//
// Radios rather than buttons, because this is a choice that persists into the
Expand Down Expand Up @@ -314,6 +366,7 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
let loadout: Loadout = options.loadout ?? DEFAULT_LOADOUT;
const changeLoadout = (next: Partial<Loadout>) => {
loadout = { ...loadout, ...next };
if (next.side && !briefing.hidden) renderBriefing(next.side);
options.onLoadoutChange?.(loadout);
};
const operator = el("div", "operator");
Expand Down Expand Up @@ -446,6 +499,18 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
button.addEventListener("click", () => options.onStart());
gate.append(button);

// Offered only while the coach is running. The cursor is locked during play,
// so a skip control in the yard could not be clicked; Esc brings the player
// here, and here it can.
const skipTraining = el("button", "gate__skip", "Skip training");
skipTraining.type = "button";
skipTraining.hidden = true;
skipTraining.addEventListener("click", () => {
skipTraining.hidden = true;
options.onSkipCoach?.();
});
gate.append(skipTraining);

const keys = el("ul", "keys");
for (const [combo, meaning] of KEYS) {
const li = el("li");
Expand Down Expand Up @@ -712,6 +777,18 @@ export function createHud(root: HTMLElement, options: HudOptions): Hud {
refreshArmory();
},

setCoach(state: CoachState | null): void {
const step = state?.step ?? null;
coach.hidden = step === null;
skipTraining.hidden = step === null;
if (!step || !state) return;
// Written only on change: this is called every frame.
const count = `TRAINING ${state.index + 1} / ${state.total}`;
if (coachCount.textContent !== count) coachCount.textContent = count;
if (coachKeys.textContent !== step.keys) coachKeys.textContent = step.keys;
if (coachAction.textContent !== step.action) coachAction.textContent = step.action;
},

dispose(): void {
for (const timer of noticeTimers) window.clearTimeout(timer);
noticeTimers.clear();
Expand Down
4 changes: 2 additions & 2 deletions apps/game/src/loadout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export const SIDES: readonly SideInfo[] = [
{
id: SIDE.NIGHTCELL,
name: "Nightcell",
blurb: "Irregulars holding the south of the yard. Cyan band.",
blurb: "Irregulars holding the south of the yard.",
team: TEAM_IDS.NIGHTCELL,
},
{
id: SIDE.DIRECTORATE,
name: "Directorate",
blurb: "Regulars holding the north. Orange band.",
blurb: "Regulars holding the north.",
team: TEAM_IDS.DIRECTORATE,
},
];
Expand Down
39 changes: 39 additions & 0 deletions apps/game/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { WeaponEffects } from "./vfx";
import { Opponents } from "./opponents";
import { createRenderer, DynamicResolution } from "./renderer";
import { buildWorld } from "./world";
import { Coach, briefingFor, hasOnboarded, markOnboarded } from "./onboarding";
import "./style.css";

/**
Expand Down Expand Up @@ -198,7 +199,23 @@ async function boot(): Promise<void> {

const player = new PlayerController(scene, camera, canvas, ARDAVAN_YARD, spawn);

// First run: a briefing on the gate, then a coach in the yard until the
// player has used every control once or skips it. Remembered, so a returning
// player sees neither.
const firstRun = !hasOnboarded(safeStorage());
let coach: Coach | null = firstRun ? new Coach() : null;
const finishCoaching = () => {
coach = null;
markOnboarded(safeStorage());
hud.setCoach(null);
};

const hud = createHud(ui, {
...(firstRun ? { briefing: briefingFor } : {}),
onSkipCoach: () => {
coach?.skip();
finishCoaching();
},
renderer: kind,
mapName: ARDAVAN_YARD.displayName,
mapChecksum: checksum,
Expand Down Expand Up @@ -434,6 +451,28 @@ async function boot(): Promise<void> {
if (packs > 0) earn(packs * CREDITS_PER_PACK, "health pack");

hud.update(status, engine.getFps(), local);

// The coach only watches while the player is actually in the yard.
if (coach && status.locked) {
const step = coach.observe({
speed: status.speed,
grounded: status.grounded,
crouching: status.crouching,
sprinting: status.sprinting,
firing: status.firing,
yaw: camera.rotation.y,
reloading: local.reloading,
slot: local.slot,
grenades: local.grenades,
alive: local.alive,
});
hud.setCoach(step);
if (step.finished) {
finishCoaching();
hud.notify("Training complete · the yard is yours");
}
}

scene.render();
});

Expand Down
183 changes: 183 additions & 0 deletions apps/game/src/onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import { FACTIONS } from "@nightcell7/game-core";
import { SIDE } from "./loadout";
import {
COACH_STEPS,
Coach,
briefingFor,
hasOnboarded,
markOnboarded,
type CoachSnapshot,
} from "./onboarding";

const IDLE: CoachSnapshot = {
speed: 0,
grounded: true,
crouching: false,
sprinting: false,
firing: false,
yaw: 0,
reloading: false,
slot: 0,
grenades: 2,
alive: true,
};

const at = (over: Partial<CoachSnapshot>): CoachSnapshot => ({ ...IDLE, ...over });

/** Drive a coach through a list of snapshots, returning the last state. */
function drive(coach: Coach, frames: CoachSnapshot[]) {
let state = coach.observe(IDLE);
for (const f of frames) state = coach.observe(f);
return state;
}

describe("coach", () => {
it("teaches in the PRD's order: move first, frag last", () => {
expect(COACH_STEPS.map((s) => s.id)).toEqual([
"move",
"look",
"sprint",
"crouch",
"jump",
"fire",
"reload",
"switch",
"frag",
]);
});

it("starts on the first step and does not advance on its own", () => {
const coach = new Coach();
for (let i = 0; i < 200; i += 1) {
const state = coach.observe(IDLE);
expect(state.step?.id).toBe("move");
}
});

it("advances only when the player does the thing", () => {
const coach = new Coach();
expect(drive(coach, [at({ speed: 3 })]).step?.id).toBe("look");
});

it("counts a real turn, not a wrap past pi", () => {
const coach = new Coach();
// Start facing just under +pi and finish "move" there, so the turn count
// begins from 3.1 rather than from a jump off yaw 0.
coach.observe(at({ yaw: 3.1 }));
expect(coach.observe(at({ yaw: 3.1, speed: 3 })).step?.id).toBe("look");
// Crossing from just under +pi to just over -pi is a 0.08 rad turn, not 6.2.
expect(coach.observe(at({ yaw: -3.1 })).step?.id).toBe("look");
// A real 0.9 rad turn completes it.
expect(coach.observe(at({ yaw: -2.2 })).step?.id).toBe("sprint");
});

it("needs a change of weapon, not merely being on a slot", () => {
const coach = new Coach();
drive(coach, [
at({ speed: 3 }),
at({ yaw: 1 }),
at({ sprinting: true }),
at({ crouching: true }),
at({ grounded: false }),
at({ firing: true }),
at({ reloading: true }),
]);
expect(coach.observe(at({ slot: 0 })).step?.id).toBe("switch");
expect(coach.observe(at({ slot: 1 })).step?.id).toBe("frag");
});

it("finishes on spending a frag, and says so exactly once", () => {
const coach = new Coach();
drive(coach, [
at({ speed: 3 }),
at({ yaw: 1 }),
at({ sprinting: true }),
at({ crouching: true }),
at({ grounded: false }),
at({ firing: true }),
at({ reloading: true }),
at({ slot: 1 }),
]);
const done = coach.observe(at({ slot: 1, grenades: 1 }));
expect(done.finished).toBe(true);
expect(done.step).toBeNull();
const after = coach.observe(at({ slot: 1, grenades: 1 }));
expect(after.finished).toBe(false);
expect(coach.complete).toBe(true);
});

it("waits while the player is dead instead of scoring the respawn", () => {
const coach = new Coach();
drive(coach, [
at({ speed: 3 }),
at({ yaw: 1 }),
at({ sprinting: true }),
at({ crouching: true }),
at({ grounded: false }),
at({ firing: true }),
at({ reloading: true }),
at({ slot: 1 }),
]);
// Died holding slot 1 with 2 frags; respawn resets to slot 0 with 2 frags.
coach.observe(at({ alive: false, slot: 1 }));
const back = coach.observe(at({ slot: 0, grenades: 2 }));
expect(back.step?.id).toBe("frag");
});

it("stops when skipped", () => {
const coach = new Coach();
coach.skip();
expect(coach.observe(at({ speed: 3 })).step).toBeNull();
expect(coach.complete).toBe(true);
});
});

describe("briefing", () => {
it("names the chosen side's institution from the shared cast", () => {
const nightcell = FACTIONS.find((f) => f.id === "nightcell")!;
const directorate = FACTIONS.find((f) => f.id === "directorate")!;
expect(briefingFor(SIDE.NIGHTCELL).lines[0]).toContain(nightcell.name);
expect(briefingFor(SIDE.NIGHTCELL).lines[0]).toContain("Rook");
expect(briefingFor(SIDE.DIRECTORATE).lines[0]).toContain(directorate.name);
expect(briefingFor(SIDE.DIRECTORATE).lines[0]).toContain("Leila");
});

it("gives both sides the same number of lines", () => {
expect(briefingFor(SIDE.NIGHTCELL).lines.length).toBe(
briefingFor(SIDE.DIRECTORATE).lines.length,
);
});
});

describe("persistence", () => {
function memoryStorage(): Storage {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: () => data.clear(),
getItem: (k) => data.get(k) ?? null,
key: (i) => [...data.keys()][i] ?? null,
removeItem: (k) => void data.delete(k),
setItem: (k, v) => void data.set(k, String(v)),
};
}

it("is not onboarded until marked", () => {
const storage = memoryStorage();
expect(hasOnboarded(storage)).toBe(false);
markOnboarded(storage);
expect(hasOnboarded(storage)).toBe(true);
});

it("treats blocked storage as onboarded rather than nagging forever", () => {
const blocked = {
getItem: () => {
throw new Error("blocked");
},
} as unknown as Storage;
expect(hasOnboarded(blocked)).toBe(true);
});
});
Loading
Loading