@@ -11,6 +11,9 @@ "devDependencies": { "typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -25,10 +25,11 @@ import { DIRECTION_VECTORS, addPos, manhattan, posKey, type GridPos } from './types'; import { MapManager } from '../world/MapManager';
import type { ExitDef, InteractableDef, MapDefinition } from '../world/MapDefinition';
import { occupantAt } from '../world/Collision';
-import { findPath, reachableSet } from '../world/Pathfinding';
+import { findPath, reachableSet, hasLineOfSight } from '../world/Pathfinding';
import { MovementSystem } from '../systems/MovementSystem';
import {
inspectEnemy,
+ inflictedCondition,
performAttack,
performCalm,
performDefend,
@@ -52,12 +53,15 @@ } from '../systems/InventorySystem'; import { DialogueSystem, applyEffect } from '../systems/DialogueSystem';
import type { DialogueNode } from '../data/dialogues/types';
import { EffectSystem } from '../systems/EffectSystem';
+import { AudioSystem } from '../systems/AudioSystem';
import { decideEnemyAction } from '../systems/EnemyAI';
import { validTargets } from '../systems/Targeting';
import { hearEcho } from '../systems/EchoSystem';
import { storeCurrentMap, takeMapState } from '../systems/MapTransitions';
import { aggroRangeOf } from '../systems/EnemyAI';
import { startQuest, advanceQuest } from '../systems/QuestSystem';
+import { hintOnce } from '../systems/HintSystem';
+import { chapterComplete } from '../systems/EndingSystem';
import { Renderer } from '../rendering/Renderer';
import { EffectRenderer } from '../rendering/EffectRenderer';
import { AnimationQueue } from '../rendering/AnimationQueue';
@@ -71,11 +75,15 @@ import { EchoView } from '../ui/EchoView'; import { SavesView, type SlotEntry } from '../ui/SavesView';
import {
buildCreditsScreen,
+ buildDeathOverlay,
+ buildEndingOverlay,
buildGlyphGalleryScreen,
buildNewGameScreen,
buildPauseOverlay,
buildSettingsScreen,
buildTitleScreen,
+ type EndingOverlay,
+ type StoryOverlay,
type TitleScreen,
} from '../ui/screens';
import { createEnemyState, createNewGameState, createNpcState } from '../entities/EntityFactory';
@@ -90,6 +98,7 @@ import { CHARACTERS, CHARACTER_IDS } from '../data/characters'; import { FLAGS, MEMORY_EVENTS } from '../data/flags';
import { QUEST_IDS } from '../data/quests';
import { GLYPH_IDS, GLYPHS } from '../data/glyphs';
+import { SHARDS } from '../data/shards';
import { validateContent } from '../utils/validation';
import { el } from '../utils/dom';
@@ -99,6 +108,12 @@ function glyphOf(id: string): string { return GLYPH_LOOKUP.get(id) ?? '✨';
}
+const PANEL_HINTS: Record<PanelId, { flag: string; text: string }> = {
+ inventory: { flag: FLAGS.hintPanelInventory, text: 'Inventory — press I to open or close it.' },
+ journal: { flag: FLAGS.hintPanelJournal, text: 'Journal — press J to open or close it.' },
+ character: { flag: FLAGS.hintPanelCharacter, text: 'Character — press C to open or close it.' },
+};
+
interface TargetingState {
abilityId: string;
targets: ActorState[];
@@ -118,11 +133,13 @@ private readonly mapManager = new MapManager(); private readonly turnManager = new TurnManager();
private readonly effects = new EffectSystem();
private readonly dialogue = new DialogueSystem();
+ private readonly audio = new AudioSystem();
private rng = new Rng(Date.now() >>> 0);
private settings: Settings;
private readonly devMode: boolean;
private state: GameState | null = null;
+ private readonly sceneFade: HTMLElement;
private renderer: Renderer | null = null;
private effectRenderer: EffectRenderer | null = null;
private animations: AnimationQueue | null = null;
@@ -135,6 +152,8 @@ private journalView: JournalView | null = null; private echoView: EchoView | null = null;
private savesView: SavesView | null = null;
private pause: ReturnType<typeof buildPauseOverlay> | null = null;
+ private death: StoryOverlay | null = null;
+ private ending: EndingOverlay | null = null;
private titleScreen: TitleScreen | null = null;
private playerTurnActive = false;
private playtimeTimer: number | null = null;
@@ -150,6 +169,9 @@ this.devMode = new URLSearchParams(window.location.search).has('dev') ||
window.location.hash === '#/gallery';
this.settings = this.loadSettings();
+ this.sceneFade = el('div', 'scene-fade');
+ this.sceneFade.setAttribute('aria-hidden', 'true');
+ this.root.append(this.sceneFade);
}
boot(): void {
@@ -161,6 +183,11 @@ } }
this.buildMenuScreens();
this.input.attach((action) => this.handleInput(action));
+ this.audio.attach();
+ this.audio.setEnabled(this.settings.sound);
+ this.audio.setVolume(this.settings.soundVolume);
+ this.audio.setAmbience('title');
+ this.bus.on('ui:toast', () => this.audio.toast());
this.applySettings(this.settings);
this.screens.show('title');
if (window.location.hash === '#/gallery') this.screens.show('gallery');
@@ -225,11 +252,12 @@ this.animations, () => this.settings.reducedMotion,
);
this.effects.connect((request) => {
- if (request.kind === 'shake') this.renderer?.shake(this.settings.screenShake);
+ if (request.kind === 'shake') this.renderer?.shake(this.settings.screenShake, request.intensity);
else this.effectRenderer?.handle(request);
});
this.hud = new HUD(gameScreen);
this.hud.onAction((actionId) => this.playerCombatAction(actionId));
+ this.hud.onTouch((action) => this.handleInput(action));
this.bus.on('ui:toast', ({ text }) => this.hud?.toast(text));
this.dialogueView = new DialogueView(gameScreen);
this.inventoryView = new InventoryView(gameScreen);
@@ -245,6 +273,16 @@ }, onQuit: () => this.quitToTitle(),
});
gameScreen.append(this.pause.element);
+ this.death = buildDeathOverlay(() => this.riseFromGrave());
+ gameScreen.append(this.death.element);
+ this.ending = buildEndingOverlay({
+ onContinue: () => this.ending?.hide(),
+ onTitle: () => {
+ this.ending?.hide();
+ this.quitToTitle();
+ },
+ });
+ gameScreen.append(this.ending.element);
this.savesView = new SavesView(this.root);
this.movement = new MovementSystem({
@@ -287,6 +325,7 @@ ); }
private showNarration(lines: string[], onDone: () => void): void {
+ this.audio.echo();
this.echoDoneCallback = onDone;
this.echoView?.show(
{ id: 'narration_prologue', glyph: '🪦', title: 'Prologue — The Grave', lines },
@@ -317,9 +356,11 @@ this.playerTurnActive = false; }
private startPlay(): void {
- this.ensureGameScreenBuilt();
- this.enterCurrentMap();
- this.screens.show('game');
+ this.fadeThrough(() => {
+ this.ensureGameScreenBuilt();
+ this.enterCurrentMap();
+ this.screens.show('game');
+ });
this.startPlaytimeClock();
this.autosave();
}
@@ -383,31 +424,48 @@ const state = this.requireState(); const renderer = this.requireRenderer();
const map = this.currentMap();
state.region = map.region;
+ this.audio.setAmbience(map.region);
if (map.onEnterQuest) startQuest(state, map.onEnterQuest);
renderer.renderMap(map);
for (const actor of allActors(state)) renderer.spawnActor(actor);
this.reconcileActors();
renderer.centerCameraOn(state.player.pos, false);
this.refreshHud();
+ this.refreshTouchInteract();
this.input.unlock();
}
private transitionTo(exit: ExitDef): void {
- const state = this.requireState();
- storeCurrentMap(state);
- const stored = takeMapState(state, exit.toMapId);
- state.mapId = exit.toMapId;
- if (stored) {
- state.actors = stored.actors;
- state.consumedInteractables = stored.consumedInteractables;
- } else {
- this.populateMapActors(this.mapManager.get(exit.toMapId));
- state.consumedInteractables = [];
- }
- state.player.pos = { ...exit.toPos };
- this.enterCurrentMap();
+ this.fadeThrough(() => {
+ const state = this.requireState();
+ storeCurrentMap(state);
+ const stored = takeMapState(state, exit.toMapId);
+ state.mapId = exit.toMapId;
+ if (stored) {
+ state.actors = stored.actors;
+ state.consumedInteractables = stored.consumedInteractables;
+ } else {
+ this.populateMapActors(this.mapManager.get(exit.toMapId));
+ state.consumedInteractables = [];
+ }
+ state.player.pos = { ...exit.toPos };
+ this.enterCurrentMap();
+ });
this.toast(exit.label);
this.autosave();
+ }
+
+ /** Fade to ink, swap screens/maps underneath, fade back. Reduced motion: instant cut. */
+ private fadeThrough(swap: () => void): void {
+ if (this.settings.reducedMotion) {
+ swap();
+ return;
+ }
+ this.sceneFade.classList.add('scene-fade--black');
+ window.setTimeout(() => {
+ swap();
+ this.sceneFade.classList.remove('scene-fade--black');
+ }, 150);
}
private checkExit(): void {
@@ -440,6 +498,14 @@ if (action.type === 'interact' || action.type === 'choice') this.echoView.advance(); else if (action.type === 'cancel') this.finishEcho();
return;
}
+ if (this.death?.visible()) {
+ if (action.type === 'interact' || action.type === 'choice') this.riseFromGrave();
+ return; // the grave waits; nothing else answers
+ }
+ if (this.ending?.visible()) {
+ if (action.type === 'interact' || action.type === 'cancel') this.ending.hide();
+ return;
+ }
if (this.itemChooserOpen) {
if (action.type === 'cancel') this.cancelItemChooser();
return;
@@ -547,7 +613,7 @@ if (occupantId) {
const occupant = findActor(state, occupantId);
if (occupant?.kind === 'enemy' && occupant.alive && occupant.faction === 'hostile') {
- this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+ this.surpriseStrike(occupant);
if (this.state?.mode === 'exploration') this.checkAggro();
return;
}
@@ -559,7 +625,9 @@ return; }
const moved = await movement.stepActor(player, to);
if (!moved || !this.state) return;
+ this.audio.footstep();
this.requireRenderer().centerCameraOn(player.pos, true);
+ this.refreshTouchInteract();
if (this.state.mode === 'exploration') {
this.checkExit();
if (this.state.mode === 'exploration') this.checkAggro();
@@ -581,6 +649,7 @@ const steps = occupantId ? path.slice(0, -1) : path; await movement.walkPath(player, steps);
if (!this.state) return;
this.requireRenderer().centerCameraOn(player.pos, true);
+ this.refreshTouchInteract();
if (occupantId) {
const occupant = findActor(state, occupantId);
if (occupant?.kind === 'npc' && manhattan(player.pos, occupant.pos) === 1) {
@@ -588,7 +657,7 @@ this.startDialogueWith(occupant); return;
}
if (occupant?.kind === 'enemy' && occupant.faction === 'hostile' && manhattan(player.pos, occupant.pos) === 1) {
- this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+ this.surpriseStrike(occupant);
}
}
if (this.state?.mode === 'exploration') {
@@ -597,6 +666,18 @@ if (this.state.mode === 'exploration') this.checkAggro(); }
}
+ /**
+ * Bumping a hostile out of combat is a surprise attack: a free Strike,
+ * and the target starts the fight Staggered (half speed for a turn).
+ */
+ private surpriseStrike(target: ActorState): void {
+ this.resolveTargetedAbility(target, ABILITY_IDS.strike);
+ if (target.alive && applyCondition(target, 'staggered', 1)) {
+ this.effects.word(target.pos, 'Surprise');
+ this.requireRenderer().refreshActor(target);
+ }
+ }
+
private interact(): void {
const state = this.requireState();
const player = state.player;
@@ -658,15 +739,23 @@ }
private checkAggro(): void {
const state = this.requireState();
+ const map = this.currentMap();
const player = state.player;
let aggroedAny = false;
for (const actor of state.actors) {
if (actor.kind !== 'enemy' || !actor.alive || actor.faction !== 'hostile' || actor.aggroed) {
continue;
}
- if (manhattan(actor.pos, player.pos) <= aggroRangeOf(actor)) {
+ // Enemies only notice what they can see: range plus line of sight.
+ if (
+ manhattan(actor.pos, player.pos) <= aggroRangeOf(actor) &&
+ hasLineOfSight(map, actor.pos, player.pos)
+ ) {
actor.aggroed = true;
aggroedAny = true;
+ if (enemyDef(actor.defId).calmable) {
+ this.hintOnce(FLAGS.hintCalm, 'That one can be Calmed — a way out without blood.');
+ }
}
}
if (aggroedAny) this.startCombat();
@@ -688,6 +777,7 @@ engaged.push(actor.id); }
}
this.bus.emit('combat:started', { enemyIds: engaged });
+ this.hintOnce(FLAGS.hintCombat, 'Combat — press a number or click an action. Defend and Wait buy time.');
this.showActionBar(true);
this.refreshTurnOrder(null);
void this.runTurns();
@@ -752,7 +842,8 @@ private tickTurnStart(actor: ActorState): boolean { const tick = tickConditions(actor);
if (tick.damage > 0) {
this.effects.damageNumber(actor.pos, tick.damage);
- if (actor.kind === 'player') this.effects.shake();
+ this.effects.hitFlash(actor.id, actor.pos, null);
+ if (actor.kind === 'player') this.effects.shake('sm');
}
for (const expired of tick.expired) {
this.effects.word(actor.pos, `${conditionDef(expired).name} faded`);
@@ -836,6 +927,7 @@ return; }
const moved = await this.requireMovement().stepActor(player, to);
if (moved && this.playerTurnActive) {
+ this.audio.footstep();
this.requireRenderer().centerCameraOn(player.pos, true);
this.endPlayerTurn(TURN_COSTS.move);
}
@@ -986,11 +1078,16 @@ spendAbilityFocus(state.player, abilityId); if (ability.cleanses) {
state.player.conditions = [];
this.effects.word(state.player.pos, 'Cleansed');
+ this.audio.calm();
}
if (ability.heals) {
const healed = Math.min(ability.heals, state.player.maxHp - state.player.hp);
state.player.hp += healed;
- if (healed > 0) this.effects.healNumber(state.player.pos, healed);
+ if (healed > 0) {
+ this.effects.healNumber(state.player.pos, healed);
+ this.effects.healFlash(state.player.id, state.player.pos);
+ this.audio.heal();
+ }
}
if (ability.selfCondition) {
applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
@@ -1028,12 +1125,15 @@ if (ability.selfCondition) { applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
}
this.effects.impact(target.pos, ability.effectGlyph);
+ this.audio.condition();
} else {
const outcome = performAttack(state.player, target, this.rng, allActors(state), {
mult: ability.damageMult,
appliesCondition: ability.appliesCondition,
selfCondition: ability.selfCondition,
});
+ if (ability.costKind === 'heavy' || ability.costKind === 'powerful') this.audio.hitHeavy();
+ else this.audio.hitLight();
void this.effectRenderer?.attackSequence(state.player.id, target.pos, ability.effectGlyph);
this.applyAttackOutcome(outcome);
if (ability.push && target.alive) {
@@ -1072,6 +1172,7 @@ private playerCalm(target: ActorState): void { const state = this.requireState();
const calmed = performCalm(state.player, target, state.tendencies.empathy, this.rng);
if (calmed) {
+ this.audio.calm();
this.effects.word(target.pos, 'Calmed');
this.effects.impact(target.pos, glyphOf(GLYPH_IDS.fxSparkle));
this.turnManager.removeActor(target.id);
@@ -1079,6 +1180,7 @@ state.flags[FLAGS.calmedWolf] = true; if (target.defId === 'ashWolf') advanceQuest(state, QUEST_IDS.prologueAwakening);
this.tendencyBump('empathy');
} else {
+ this.audio.condition();
this.effects.word(target.pos, 'Resisted');
}
this.endPlayerTurn(TURN_COSTS.attack);
@@ -1098,7 +1200,11 @@ this.toast(result.message); return;
}
const def = itemDef(itemId);
- if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+ if (def.healAmount) {
+ this.effects.healNumber(state.player.pos, def.healAmount);
+ this.effects.healFlash(state.player.id, state.player.pos);
+ this.audio.heal();
+ }
this.requireRenderer().refreshActor(state.player);
this.refreshHud();
this.endPlayerTurn(TURN_COSTS.item);
@@ -1130,16 +1236,50 @@ const target = findActor(state, intent.targetId); if (target && target.alive) {
const effectGlyph =
intent.type === 'rangedAttack' ? glyphOf(GLYPH_IDS.fxArrow) : glyphOf(GLYPH_IDS.fxSlash);
+ const mult = intent.type === 'attack' ? (intent.mult ?? 1) : 1;
+ if (mult > 1) {
+ this.effects.word(enemy.pos, 'Heavy Blow');
+ this.audio.hitHeavy();
+ } else {
+ this.audio.hitLight();
+ }
if (intent.type === 'rangedAttack') {
await this.effectRenderer?.rangedSequence(target.pos);
} else {
await this.effectRenderer?.attackSequence(enemy.id, target.pos, effectGlyph);
}
- const inflicts = enemy.kind === 'enemy' ? enemyDef(enemy.defId).appliesCondition : undefined;
const outcome = performAttack(enemy, target, this.rng, allActors(state), {
- appliesCondition: inflicts,
+ mult,
+ appliesCondition: inflictedCondition(enemy, target, allActors(state)),
});
this.applyAttackOutcome(outcome);
+ // Guard Stance: a melee hit on a watching player is answered with a free Strike.
+ if (
+ intent.type === 'attack' &&
+ target.kind === 'player' &&
+ target.alive &&
+ enemy.alive &&
+ hasCondition(target, 'guarding')
+ ) {
+ this.effects.word(target.pos, 'Counter');
+ this.audio.hitLight();
+ this.applyAttackOutcome(performAttack(target, enemy, this.rng, allActors(state), {}));
+ }
+ cost = TURN_COSTS.attack;
+ }
+ } else if (intent.type === 'push') {
+ const target = findActor(state, intent.targetId);
+ if (target && target.alive) {
+ const push = performPush(enemy, target, (pos) =>
+ this.requireMovement().checkStep(target, pos).ok,
+ );
+ if (push.moved) {
+ this.effects.word(target.pos, 'Pushed');
+ void this.animations?.enqueue(() => this.requireRenderer().placeActor(target, true));
+ } else {
+ this.effects.word(target.pos, 'Staggered');
+ this.requireRenderer().refreshActor(target);
+ }
cost = TURN_COSTS.attack;
}
} else if (intent.type === 'guard') {
@@ -1159,24 +1299,43 @@ private applyAttackOutcome(outcome: AttackOutcome): void { const state = this.requireState();
const defender = findActor(state, outcome.defenderId);
if (!defender) return;
+ const attacker = findActor(state, outcome.attackerId);
this.effects.damageNumber(defender.pos, outcome.damage);
- if (defender.kind === 'player') this.effects.shake();
+ this.effects.hitFlash(defender.id, defender.pos, attacker?.pos ?? null);
+ this.shakeForHit(defender, outcome.damage);
+ if (defender.kind === 'player') this.audio.playerHurt();
for (const applied of outcome.conditionsApplied) {
this.effects.word(defender.pos, conditionDef(applied).name);
}
+ if (outcome.conditionsApplied.length > 0) this.audio.condition();
for (const broken of outcome.brokenConditions) {
this.effects.word(defender.pos, `${conditionDef(broken).name} broke`);
}
this.requireRenderer().refreshActor(defender);
- const attacker = findActor(state, outcome.attackerId);
if (attacker) this.requireRenderer().refreshActor(attacker);
if (outcome.killed) this.handleDeath(defender);
this.refreshHud();
}
+ /**
+ * Shake communicates impact on the player and big events: player hits are
+ * always at least medium (large at ≥50% max HP, with a hurt vignette from
+ * 25% up); enemies only shake the screen on solid hits (≥25%).
+ */
+ private shakeForHit(defender: ActorState, damage: number): void {
+ const ratio = damage / defender.maxHp;
+ if (defender.kind === 'player') {
+ this.effects.shake(ratio >= 0.5 ? 'lg' : 'md');
+ if (ratio >= 0.25) this.renderer?.hurtVignette();
+ } else if (ratio >= 0.25) {
+ this.effects.shake('sm');
+ }
+ }
+
private handleDeath(actor: ActorState): void {
const state = this.requireState();
this.turnManager.removeActor(actor.id);
+ this.effects.shake('lg'); // a death always lands heavy
if (actor.kind === 'player') {
this.playerDeath();
return;
@@ -1203,23 +1362,38 @@ if (actor.kind === 'enemy' && actor.defId === 'ashWolf') { state.flags[FLAGS.killedWolf] = true;
advanceQuest(state, QUEST_IDS.prologueAwakening);
}
+ this.audio.deathKnell();
this.requireRenderer().refreshActor(actor);
}
+ /**
+ * Death: enemies lose interest, the world dims behind a verse, and the
+ * grave gives you back — at the cost of your focus. The respawn itself
+ * happens on "Rise again" (or Enter), in place: no map reload.
+ */
private playerDeath(): void {
const state = this.requireState();
+ for (const actor of state.actors) actor.aggroed = false;
+ if (state.mode === 'combat') this.endCombat('died');
+ this.audio.deathKnell();
+ this.audio.duckAmbience(true);
+ this.death?.show();
+ }
+
+ private riseFromGrave(): void {
+ this.death?.hide();
+ this.audio.duckAmbience(false);
+ const state = this.requireState();
const player = state.player;
this.toast('The grave remembers you. It gives you back.');
player.alive = true;
player.hp = player.maxHp;
- player.focus = Math.max(1, player.focus);
+ player.focus = 0;
player.conditions = [];
player.pos = { ...this.currentMap().playerSpawn };
- for (const actor of state.actors) actor.aggroed = false;
this.requireRenderer().placeActor(player, false);
this.requireRenderer().refreshActor(player);
this.requireRenderer().centerCameraOn(player.pos, false);
- if (state.mode === 'combat') this.endCombat('died');
}
private tendencyBump(axis: 'resolve' | 'insight' | 'empathy' | undefined): void {
@@ -1270,11 +1444,13 @@ const state = this.requireState(); const choices = this.dialogue.availableChoices(node, state);
this.dialogueView?.show(node, choices, {
onChoice: (choiceId) => {
+ this.audio.uiSelect();
const next = this.dialogue.choose(choiceId, state);
if (next) this.showDialogueNode(next);
else this.endDialogue();
},
onAdvance: () => {
+ this.audio.dialogueAdvance();
const next = this.dialogue.advance(state);
if (next) this.showDialogueNode(next);
else this.endDialogue();
@@ -1293,17 +1469,36 @@ this.reconcileActors(); this.checkAbilityUnlocks();
this.refreshHud();
this.autosave();
+ // Every shard the current content can award, carried: the chapter closes.
+ if (!state.flags[FLAGS.chapterCompleteShown] && chapterComplete(state)) {
+ state.flags[FLAGS.chapterCompleteShown] = true;
+ this.showEnding('chapterComplete');
+ }
// A dialogue may have turned an NPC hostile — combat starts now.
if (state.mode === 'exploration' && livingHostiles(state).some((a) => a.aggroed)) {
this.startCombat();
}
}
+ /**
+ * "Chapter complete" overlay. Not an ending — only the shards the current
+ * content can award exist, so this fires when all of them are carried.
+ */
+ private showEnding(_reason: 'chapterComplete'): void {
+ const state = this.requireState();
+ const carried = state.shards
+ .map((id) => SHARDS[id])
+ .filter((shard): shard is (typeof SHARDS)[string] => !!shard);
+ this.ending?.showFor(carried);
+ this.audio.echo();
+ }
+
// ---- name echoes --------------------------------------------------------
private openEcho(echoId: string): void {
const state = this.requireState();
const result = hearEcho(state, echoId);
+ this.audio.echo();
this.echoDoneCallback = () => {
if (result.grantedAbility) {
this.toast(`New ability: ${abilityDef(result.grantedAbility).name}`);
@@ -1342,10 +1537,14 @@ this.characterView?.show(state, () => this.closePanels()); } else {
this.journalView?.show(state, () => this.closePanels());
}
+ this.audio.panel(true);
this.openPanel = panel;
+ const hint = PANEL_HINTS[panel];
+ this.hintOnce(hint.flag, hint.text);
}
private closePanels(): void {
+ if (this.openPanel) this.audio.panel(false);
this.openPanel = null;
this.inventoryView?.hide();
this.characterView?.hide();
@@ -1383,7 +1582,11 @@ const result = useConsumable(state, itemId); this.toast(result.message);
if (result.ok) {
const def = itemDef(itemId);
- if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+ if (def.healAmount) {
+ this.effects.healNumber(state.player.pos, def.healAmount);
+ this.effects.healFlash(state.player.id, state.player.pos);
+ this.audio.heal();
+ }
this.requireRenderer().refreshActor(state.player);
}
this.refreshHud();
@@ -1401,9 +1604,11 @@ private openPause(): void {
if (this.state?.mode === 'dialogue') return;
this.pause?.show();
+ this.audio.panel(true);
}
private closePause(): void {
+ if (this.pause?.visible()) this.audio.panel(false);
this.pause?.hide();
}
@@ -1490,7 +1695,8 @@ if (this.playtimeTimer !== null) { window.clearInterval(this.playtimeTimer);
this.playtimeTimer = null;
}
- this.screens.show('title');
+ this.fadeThrough(() => this.screens.show('title'));
+ this.audio.setAmbience('title');
}
// ---- settings / misc ---------------------------------------------------
@@ -1516,6 +1722,8 @@ // storage full or blocked — settings just won't persist }
document.body.classList.toggle('reduced-motion', settings.reducedMotion);
document.documentElement.style.setProperty('--text-scale', String(settings.textScale));
+ this.audio.setEnabled(settings.sound);
+ this.audio.setVolume(settings.soundVolume);
this.renderer?.setMotionOptions(settings.reducedMotion, settings.animationSpeed);
}
@@ -1552,6 +1760,35 @@ }
private toast(text: string): void {
this.bus.emit('ui:toast', { text });
+ }
+
+ /** One-time contextual hint (persisted as a world flag), shown as a longer toast. */
+ private hintOnce(flagId: string, text: string): void {
+ if (!this.state) return;
+ const hint = hintOnce(this.state.flags, flagId, text);
+ if (hint) this.hud?.toast(hint, 5200);
+ }
+
+ /** The touch Act button only lights up next to someone/something interactive. */
+ private refreshTouchInteract(): void {
+ const state = this.state;
+ if (!state || state.mode !== 'exploration') {
+ this.hud?.setTouchInteractVisible(false);
+ return;
+ }
+ const player = state.player;
+ const candidates = [
+ addPos(player.pos, DIRECTION_VECTORS[player.facing]),
+ ...Object.values(DIRECTION_VECTORS).map((v) => addPos(player.pos, v)),
+ ];
+ const visible = candidates.some(
+ (pos) =>
+ state.actors.some(
+ (a) => a.kind === 'npc' && a.alive && a.pos.x === pos.x && a.pos.y === pos.y,
+ ) ||
+ this.currentMap().interactables.some((i) => i.pos.x === pos.x && i.pos.y === pos.y),
+ );
+ this.hud?.setTouchInteractVisible(visible);
}
private requireState(): GameState {
@@ -69,6 +69,9 @@ /** Multiplier on animation durations: 0.5 fast, 1 normal, 2 slow. */ animationSpeed: number;
screenShake: boolean;
textScale: number;
+ sound: boolean;
+ /** Master volume, 0..1. */
+ soundVolume: number;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -76,6 +79,8 @@ reducedMotion: false, animationSpeed: 1,
screenShake: true,
textScale: 1,
+ sound: true,
+ soundVolume: 1,
};
export type GameMode = 'exploration' | 'combat' | 'dialogue';
@@ -55,10 +55,10 @@ export type ConditionId =
| 'bleeding'
| 'burning'
- | 'frozen'
| 'staggered'
| 'afraid'
| 'guarded'
+ | 'guarding'
| 'silenced'
| 'forgotten'
| 'remembered'
@@ -102,12 +102,13 @@ [ABILITY_IDS.guardStance]: { id: ABILITY_IDS.guardStance,
name: 'Guard Stance',
glyph: glyphOf(GLYPH_IDS.fxShield),
- description: 'Brace behind your guard (Guarded). Quick recovery.',
+ description:
+ 'Brace and watch: +50% defense until your next turn, and answer any melee hit with a free counter. Quick recovery.',
costKind: 'defend',
damageMult: 0,
range: 0,
focusCost: 0,
- selfCondition: { id: 'guarded', duration: 1 },
+ selfCondition: { id: 'guarding', duration: 1 },
tendency: 'resolve',
effectGlyph: glyphOf(GLYPH_IDS.fxShield),
},
@@ -46,19 +46,9 @@ burning: { id: 'burning',
name: 'Burning',
glyph: glyphOf(GLYPH_IDS.condBurning),
- tooltip: 'Loses 3 health at the start of each turn. Water extinguishes it.',
+ tooltip: 'Loses 3 health at the start of each turn. Ember salve soothes it.',
defaultDuration: 2,
tickDamage: 3,
- },
- frozen: {
- id: 'frozen',
- name: 'Frozen',
- glyph: glyphOf(GLYPH_IDS.condFrozen),
- tooltip: 'Cannot move or act. Breaks when struck.',
- defaultDuration: 1,
- speedMult: 0,
- preventsAction: true,
- breaksOnHit: true,
},
staggered: {
id: 'staggered',
@@ -81,6 +71,15 @@ id: 'guarded', name: 'Guarded',
glyph: glyphOf(GLYPH_IDS.condGuarded),
tooltip: 'Braced. Defense increased by 50% until next turn.',
+ defaultDuration: 1,
+ defenseMult: 1.5,
+ },
+ guarding: {
+ id: 'guarding',
+ name: 'Guard Stance',
+ glyph: glyphOf(GLYPH_IDS.condGuarded),
+ tooltip:
+ 'Braced and watching. Defense increased by 50% until next turn; melee attackers are countered.',
defaultDuration: 1,
defenseMult: 1.5,
},
@@ -54,8 +54,9 @@ moveRange: 3, attackRange: 1,
packTactics: true,
calmable: true,
+ appliesCondition: { id: 'bleeding', duration: 3 },
inspectText:
- 'Hungry, but not cruel. It hunts because the woods forgot how to feed it. It might remember kindness.',
+ 'Hungry, but not cruel. It hunts because the woods forgot how to feed it. In a pack, its bites bleed. It might remember kindness.',
},
[ENEMY_IDS.hollowSoldier]: {
id: ENEMY_IDS.hollowSoldier,
@@ -91,7 +92,9 @@ moveArchetype: 'default', aggroRange: 6,
moveRange: 2,
attackRange: 4,
- inspectText: 'Keeps distance, counts arrows, follows orders. The orders are old.',
+ appliesCondition: { id: 'unstable', duration: 2 },
+ inspectText:
+ 'Keeps distance, counts arrows, follows orders. The orders are old. Its arrows rattle your glyph loose.',
},
[ENEMY_IDS.crownSoldier]: {
id: ENEMY_IDS.crownSoldier,
@@ -146,8 +149,9 @@ moveArchetype: 'heavy', aggroRange: 3,
moveRange: 1,
attackRange: 1,
+ appliesCondition: { id: 'burning', duration: 2 },
inspectText:
- 'Advance. Strike. Wind down. Repeat. It builds weapons for an army of the dead, on schedule.',
+ 'Advance. Strike. Wind down. Repeat. It builds weapons for an army of the dead, on schedule. Its fists still carry the forge.',
},
};
@@ -8,6 +8,8 @@ killedWolf: 'killedWolf', readGrave: 'readGrave',
tookRustedSword: 'tookRustedSword',
tookWeatheredCloak: 'tookWeatheredCloak',
+ tookHuntingBow: 'tookHuntingBow',
+ tookDrownedLetter: 'tookDrownedLetter',
// prologue — Wayrest
wayrestForgot: 'wayrestForgot',
insistedTamExisted: 'insistedTamExisted',
@@ -49,6 +51,13 @@ kingAlly: 'kingAlly', kingFought: 'kingFought',
endingsUnlocked: 'endingsUnlocked',
gameCompleted: 'gameCompleted',
+ // onboarding hints (one-time toasts, persisted per save)
+ hintCombat: 'hintCombat',
+ hintCalm: 'hintCalm',
+ hintPanelInventory: 'hintPanelInventory',
+ hintPanelJournal: 'hintPanelJournal',
+ hintPanelCharacter: 'hintPanelCharacter',
+ chapterCompleteShown: 'chapterCompleteShown',
} as const;
export const MEMORY_EVENTS = {
@@ -49,9 +49,9 @@ [ITEM_IDS.huntingBow]: { id: ITEM_IDS.huntingBow,
name: 'Hunting Bow',
glyph: glyphOf(GLYPH_IDS.itemHuntingBow),
- type: 'weapon',
- description: 'Glasswood yew. The string hums near mirrors. +1 attack.',
- attackBonus: 1,
+ type: 'relic',
+ description:
+ 'Glasswood yew, the string long snapped. It hums faintly near mirrors, as if it remembers flying.',
},
[ITEM_IDS.weatheredCloak]: {
id: ITEM_IDS.weatheredCloak,
@@ -72,6 +72,16 @@ text: 'The water does not reflect you. It has not decided what you are yet.', },
},
{
+ id: 'wolfTracks',
+ pos: { x: 10, y: 4 },
+ glyph: null,
+ name: 'Wolf tracks',
+ action: {
+ type: 'inspect',
+ text: 'The tracks circle the clearing and never close in. Hunger moves them — but under the hunger, fear. It might be spoken to.',
+ },
+ },
+ {
id: 'mirrorShard',
pos: { x: 13, y: 4 },
glyph: GLYPHS.find((g) => g.id === GLYPH_IDS.echoMirror)?.glyph ?? null,
@@ -1,6 +1,7 @@ import { defineMap, type MapDefinition } from '../../world/MapDefinition';
import { CHARACTER_IDS } from '../characters';
import { ENEMY_IDS } from '../enemies';
+import { ITEM_IDS } from '../items';
import { FLAGS, MEMORY_EVENTS } from '../flags';
import { GLYPH_IDS, GLYPHS } from '../glyphs';
import { QUEST_IDS } from '../quests';
@@ -41,6 +42,18 @@ { defId: ENEMY_IDS.mireLurker, pos: { x: 7, y: 6 } }, { defId: ENEMY_IDS.mireLurker, pos: { x: 11, y: 7 } },
],
interactables: [
+ {
+ id: 'drownedLetter',
+ pos: { x: 12, y: 4 },
+ glyph: null,
+ name: 'A waterproofed letter',
+ action: {
+ type: 'item',
+ itemId: ITEM_IDS.letterToTheDrowned,
+ onceFlag: FLAGS.tookDrownedLetter,
+ afterText: 'Only dark water, and the feeling of being politely watched.',
+ },
+ },
{
id: 'westBell',
pos: { x: 1, y: 6 },
@@ -11,7 +11,8 @@ }
// The Old Road: a short transit map south of the grave clearing. Proves exit
// traversal, per-map persistence, a patrolling Hollow soldier, an echo object
-// and an armor pickup. Region content arrives in a later phase.
+// and an armor pickup. A lone Rust Machine — vanguard of a march that never
+// arrived — stands guard off the road, over a snapped bow.
export const OLD_ROAD: MapDefinition = defineMap({
id: 'oldRoad',
name: 'The Old Road',
@@ -43,6 +44,7 @@ { x: 8, y: 4 }, { x: 8, y: 8 },
],
},
+ { defId: ENEMY_IDS.rustMachine, pos: { x: 4, y: 7 } },
],
interactables: [
{
@@ -62,6 +64,18 @@ type: 'item', itemId: ITEM_IDS.weatheredCloak,
onceFlag: FLAGS.tookWeatheredCloak,
afterText: 'The pack is empty now. Whoever left it walked north, toward the graves.',
+ },
+ },
+ {
+ id: 'snappedBow',
+ pos: { x: 2, y: 9 },
+ glyph: null,
+ name: 'A snapped bow',
+ action: {
+ type: 'item',
+ itemId: ITEM_IDS.huntingBow,
+ onceFlag: FLAGS.tookHuntingBow,
+ afterText: 'Only the rust-imprints of the machine’s feet keep the grass company now.',
},
},
{
@@ -29,10 +29,34 @@ break; case 'floatingText':
this.renderer.spawnFloatingText(request.pos, request.text, request.tone);
break;
+ case 'flash':
+ this.flash(request);
+ break;
case 'shake':
- this.renderer.shake(true);
+ this.renderer.shake(true, request.intensity);
break;
}
+ }
+
+ /**
+ * Hit feedback on the actor itself: a brief color flash and a 3px recoil
+ * away from the attacker. Skipped entirely under reduced motion.
+ */
+ private flash(request: Extract<EffectRequest, { kind: 'flash' }>): void {
+ if (this.isReducedMotion()) return;
+ const node = this.renderer.actorElement(request.actorId);
+ if (!node) return;
+ if (request.tone === 'damage' && request.from) {
+ const dx = Math.sign(request.pos.x - request.from.x);
+ const dy = Math.sign(request.pos.y - request.from.y);
+ node.style.setProperty('--knock-x', `${dx * 3}px`);
+ node.style.setProperty('--knock-y', `${dy * 3}px`);
+ }
+ const cls = request.tone === 'heal' ? 'actor--flash-heal' : 'actor--flash-damage';
+ node.classList.remove(cls);
+ void node.offsetWidth; // force reflow so the animation restarts
+ node.classList.add(cls);
+ setTimeout(() => node.classList.remove(cls), 160);
}
/** The full attack beat. Returns a promise that resolves when it lands. */
@@ -10,6 +10,7 @@ import { conditionDef } from '../data/conditions'; import { CHARACTERS } from '../data/characters';
import { ENEMIES } from '../data/enemies';
import { hasCondition } from '../systems/ConditionSystem';
+import type { ShakeIntensity } from '../systems/EffectSystem';
/**
* Layered DOM renderer: terrain CSS Grid, absolutely positioned actors moved
@@ -25,6 +26,7 @@ private readonly propsLayer: HTMLElement; private readonly highlightLayer: HTMLElement;
private readonly actorLayer: HTMLElement;
private readonly effectLayer: HTMLElement;
+ private readonly vignette: HTMLElement;
private readonly camera: Camera;
private readonly actorElements = new Map<string, HTMLElement>();
private map: MapDefinition | null = null;
@@ -49,6 +51,9 @@ this.effectLayer, );
this.viewport.append(this.world);
this.root.append(this.viewport);
+ this.vignette = el('div', 'hurt-vignette');
+ this.vignette.setAttribute('aria-hidden', 'true');
+ this.root.append(this.vignette);
parent.append(this.root);
this.camera = new Camera(this.viewport, this.world);
}
@@ -107,6 +112,7 @@ spawnActor(actor: ActorState): void {
this.removeActor(actor.id);
const node = el('div', `actor actor--${archetypeOf(actor)}`);
+ if (actor.kind === 'player') node.classList.add('actor--player');
node.dataset.actorId = actor.id;
node.style.width = `${TILE_SIZE}px`;
node.style.height = `${TILE_SIZE}px`;
@@ -140,7 +146,7 @@ refreshActor(actor: ActorState): void {
const node = this.actorElements.get(actor.id);
if (!node) return;
- node.classList.toggle('actor--dead', !actor.alive);
+ this.updateDeathState(node, actor);
node.classList.toggle('actor--unstable', hasCondition(actor, 'unstable'));
const hpFill = node.querySelector('.actor__hp i');
if (hpFill instanceof HTMLElement) {
@@ -156,11 +162,34 @@ } const def = conditionDef(first.id);
chip.textContent = def.glyph;
(chip as HTMLElement).title = `${def.name} — ${def.tooltip}`;
+ (chip as HTMLElement).setAttribute('aria-label', `${def.name} — ${def.tooltip}`);
} else if (chip instanceof HTMLElement) {
chip.remove();
}
}
+ /**
+ * Death plays a fade+squash beat (longer for the player) before settling
+ * into the static actor--dead state. Reduced motion skips straight to it.
+ */
+ private updateDeathState(node: HTMLElement, actor: ActorState): void {
+ if (actor.alive) {
+ node.classList.remove('actor--dead', 'actor--dying'); // revived (respawn)
+ return;
+ }
+ if (node.classList.contains('actor--dead') || node.classList.contains('actor--dying')) return;
+ if (this.reducedMotion) {
+ node.classList.add('actor--dead');
+ return;
+ }
+ node.classList.add('actor--dying');
+ const settleMs = actor.kind === 'player' ? 700 : 400;
+ setTimeout(() => {
+ node.classList.remove('actor--dying');
+ node.classList.add('actor--dead');
+ }, settleMs);
+ }
+
placeActor(actor: ActorState, animate: boolean): Promise<void> {
const node = this.actorElements.get(actor.id);
if (!node) return Promise.resolve();
@@ -260,12 +289,21 @@ this.effectLayer.append(node); setTimeout(() => node.remove(), this.reducedMotion ? 60 : 1100);
}
- shake(enabled: boolean): void {
+ shake(enabled: boolean, intensity: ShakeIntensity = 'md'): void {
if (!enabled || this.reducedMotion) return;
- this.viewport.classList.remove('is-shaking');
+ this.viewport.classList.remove('is-shaking-sm', 'is-shaking-md', 'is-shaking-lg');
// force reflow so the animation restarts
void this.viewport.offsetWidth;
- this.viewport.classList.add('is-shaking');
+ this.viewport.classList.add(`is-shaking-${intensity}`);
+ }
+
+ /** Dark red edge pulse when the player takes a heavy hit. */
+ hurtVignette(): void {
+ if (this.reducedMotion) return;
+ this.vignette.classList.remove('is-hurt');
+ void this.vignette.offsetWidth;
+ this.vignette.classList.add('is-hurt');
+ setTimeout(() => this.vignette.classList.remove('is-hurt'), 340);
}
tileKeyOf(pos: GridPos): string {
@@ -129,37 +129,125 @@ .actor--unstable .actor__body { animation: flicker 900ms linear infinite;
}
-/* screen shake (settings-toggleable) */
+/* hit feedback: brief color flash + 3px recoil (direction via --knock-* vars) */
+@keyframes flash-damage {
+ 0% {
+ filter: brightness(2.4) drop-shadow(0 0 6px var(--danger));
+ transform: translate(var(--knock-x, 0), var(--knock-y, 0));
+ }
+ 100% {
+ filter: none;
+ transform: translate(0, 0);
+ }
+}
+
+@keyframes flash-heal {
+ 0% {
+ filter: brightness(1.8) drop-shadow(0 0 6px var(--amber));
+ }
+ 100% {
+ filter: none;
+ }
+}
+
+.actor--flash-damage .actor__body {
+ animation: flash-damage 140ms ease-out;
+}
+
+.actor--flash-heal .actor__body {
+ animation: flash-heal 160ms ease-out;
+}
+
+/* death: fade + squash into the fallen pose (actor--dead holds it) */
+@keyframes dying-squash {
+ 60% {
+ opacity: 0.5;
+ transform: scale(0.92) rotate(-4deg);
+ }
+ 100% {
+ opacity: 0.35;
+ transform: scale(0.85) rotate(-6deg);
+ }
+}
+
+.actor--dying .actor__body {
+ transform-origin: 50% 100%;
+ animation: dying-squash 400ms ease-in forwards;
+}
+
+.actor--dying.actor--player .actor__body {
+ animation-duration: 700ms;
+}
+
+/* dark red edge pulse on heavy player damage */
+@keyframes hurt-pulse {
+ 0% {
+ opacity: 0;
+ }
+ 20% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
+
+/* screen shake, amplitude scaled by --shake-amp (settings-toggleable) */
@keyframes shake {
0%,
100% {
transform: translate(0, 0);
}
20% {
- transform: translate(-4px, 2px);
+ transform: translate(calc(var(--shake-amp) * -1), calc(var(--shake-amp) * 0.5));
}
40% {
- transform: translate(3px, -3px);
+ transform: translate(calc(var(--shake-amp) * 0.75), calc(var(--shake-amp) * -0.75));
}
60% {
- transform: translate(-2px, -2px);
+ transform: translate(calc(var(--shake-amp) * -0.5), calc(var(--shake-amp) * -0.5));
}
80% {
- transform: translate(2px, 3px);
+ transform: translate(calc(var(--shake-amp) * 0.5), calc(var(--shake-amp) * 0.75));
}
}
-.is-shaking {
+.is-shaking-sm {
+ --shake-amp: 2px;
+ animation: shake 160ms linear;
+}
+
+.is-shaking-md {
+ --shake-amp: 4px;
animation: shake 280ms linear;
}
+.is-shaking-lg {
+ --shake-amp: 7px;
+ animation: shake 380ms linear;
+}
+
@media (prefers-reduced-motion: reduce) {
.actor--moving .actor__body,
.actor--unstable .actor__body,
- .is-shaking {
+ .actor--flash-damage .actor__body,
+ .actor--flash-heal .actor__body,
+ .actor--dying .actor__body,
+ .hurt-vignette.is-hurt,
+ .is-shaking-sm,
+ .is-shaking-md,
+ .is-shaking-lg {
animation: none;
}
+ .scene-fade {
+ transition: none;
+ }
+
+ .story-overlay {
+ transition-duration: 60ms;
+ }
+
.effect,
.float-text {
animation-duration: 60ms;
@@ -169,8 +257,22 @@ /* settings toggle mirrors the media query */
body.reduced-motion .actor--moving .actor__body,
body.reduced-motion .actor--unstable .actor__body,
-body.reduced-motion .is-shaking {
+body.reduced-motion .actor--flash-damage .actor__body,
+body.reduced-motion .actor--flash-heal .actor__body,
+body.reduced-motion .actor--dying .actor__body,
+body.reduced-motion .hurt-vignette.is-hurt,
+body.reduced-motion .is-shaking-sm,
+body.reduced-motion .is-shaking-md,
+body.reduced-motion .is-shaking-lg {
animation: none;
+}
+
+body.reduced-motion .scene-fade {
+ transition: none;
+}
+
+body.reduced-motion .story-overlay {
+ transition-duration: 60ms;
}
body.reduced-motion .effect,
@@ -220,6 +220,12 @@ opacity: 0.35; filter: grayscale(1);
}
+/* the fallen pose the dying animation settles into */
+.actor--dead .actor__body {
+ transform: scale(0.85) rotate(-6deg);
+ transform-origin: 50% 100%;
+}
+
/* Effects & floating text */
.effect {
position: absolute;
@@ -419,3 +425,100 @@ .hud__toast--visible {
opacity: 1;
}
+
+/* Dark red edge pulse when the player takes a heavy hit (Renderer.hurtVignette). */
+.hurt-vignette {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0;
+ box-shadow: inset 0 0 90px 30px rgb(168 64 47 / 0.45);
+ z-index: 5;
+}
+
+.hurt-vignette.is-hurt {
+ animation: hurt-pulse 320ms ease-out;
+}
+
+/* Full-screen ink wash for map/screen transitions (Game.fadeThrough). */
+.scene-fade {
+ position: fixed;
+ inset: 0;
+ background: var(--ink);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 200ms ease-out;
+ z-index: 40;
+}
+
+.scene-fade--black {
+ opacity: 1;
+ transition-duration: 150ms;
+}
+
+/* Focus-visible tooltip: the title text, readable without a mouse. */
+[data-tip] {
+ position: relative;
+}
+
+[data-tip]:focus-visible::after {
+ content: attr(data-tip);
+ position: absolute;
+ bottom: calc(100% + 6px);
+ left: 50%;
+ transform: translateX(-50%);
+ width: max-content;
+ max-width: 240px;
+ white-space: normal;
+ background: var(--ink-2);
+ color: var(--paper);
+ border: var(--border-subtle);
+ border-radius: 4px;
+ padding: 0.3rem 0.6rem;
+ font-size: calc(0.75rem * var(--text-scale));
+ font-style: normal;
+ z-index: 50;
+ pointer-events: none;
+}
+
+/* Touch controls: only on coarse pointers, big enough for thumbs. */
+.hud__touch {
+ display: none;
+ position: absolute;
+ right: 0.75rem;
+ bottom: 18%;
+ flex-direction: column;
+ gap: 0.5rem;
+ pointer-events: auto;
+}
+
+.hud__touch-button {
+ min-width: 64px;
+ min-height: 44px;
+ padding: 0.5em 0.9em;
+ background: rgb(24 21 17 / 0.85);
+ border: var(--border-subtle);
+ border-radius: 6px;
+ color: var(--paper);
+ font-size: calc(0.9rem * var(--text-scale));
+}
+
+.hud__touch-button--dim {
+ opacity: 0.35;
+}
+
+@media (pointer: coarse) {
+ .hud__touch {
+ display: flex;
+ }
+
+ .action-button {
+ font-size: calc(1rem * var(--text-scale));
+ padding: 0.7em 1.1em;
+ min-height: 44px;
+ }
+
+ .menu__button {
+ padding: 0.8em 1.6em;
+ }
+}
@@ -226,6 +226,63 @@ padding: 1.5rem 2rem; min-width: 280px;
}
+/* Story overlays (death, chapter complete): ink-dark, verses, one choice */
+.story-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgb(16 14 12 / 0.92);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 600ms ease;
+ z-index: 32;
+}
+
+.story-overlay--visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.story-overlay__panel {
+ max-width: min(460px, 88vw);
+ text-align: center;
+ padding: 1.5rem 2rem;
+}
+
+.story-overlay__line {
+ color: var(--paper-dim);
+ font-style: italic;
+ line-height: 1.7;
+ margin: 0.35rem 0;
+}
+
+.story-overlay__panel .menu__nav {
+ margin-top: 1.4rem;
+ align-items: center;
+}
+
+.story-overlay__shards {
+ display: flex;
+ gap: 1.2rem;
+ justify-content: center;
+ margin: 0.8rem 0 0.4rem;
+}
+
+.story-overlay__shard {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 1.7rem;
+}
+
+.story-overlay__shard-name {
+ font-size: calc(0.72rem * var(--text-scale));
+ color: var(--paper-faint);
+}
+
/* Full panels: inventory, character, journal, saves */
.panel-overlay {
position: absolute;
@@ -0,0 +1,352 @@+/**
+ * Procedural audio: every sound is synthesized with the Web Audio API —
+ * there are no audio files. The AudioContext is created lazily on the
+ * first user gesture (browser autoplay policy); until then, and in
+ * non-browser environments (tests), every method is a safe no-op.
+ */
+
+/** Master output ceiling; individual sounds carry their own gains. */
+const MASTER_LEVEL = 0.8;
+/** Ambient drone level — deliberately barely-there. */
+const DRONE_LEVEL = 0.05;
+/** Seconds for the drone pitch/gain glide when the region changes. */
+const DRONE_CROSSFADE_S = 1;
+
+interface Ambience {
+ /** Drone fundamental in Hz. */
+ base: number;
+ /** Bandpass center of the wind noise. */
+ wind: number;
+}
+
+/** Region name (or 'title') -> drone tuning. */
+const AMBIENCES: Record<string, Ambience> = {
+ title: { base: 36.71, wind: 240 }, // D1 — lower than any place in the world
+ 'The Nameless Edge': { base: 55, wind: 380 }, // A1
+ 'The Glasswood': { base: 82.41, wind: 660 }, // E2 — brighter, glassier air
+ 'The Mire of Bells': { base: 65.41, wind: 480 }, // C2
+};
+
+const FALLBACK_AMBIENCE: Ambience = { base: 49, wind: 340 }; // G1
+
+interface Drone {
+ oscA: OscillatorNode;
+ oscB: OscillatorNode;
+ windFilter: BiquadFilterNode;
+ gain: GainNode;
+}
+
+interface ToneOpts {
+ freq: number;
+ type?: OscillatorType;
+ attack?: number;
+ decay?: number;
+ gain?: number;
+ delay?: number;
+ /** Glide to this frequency across the decay (for impacts). */
+ freqEnd?: number;
+}
+
+interface NoiseOpts {
+ filterType: BiquadFilterType;
+ freq: number;
+ q?: number;
+ attack?: number;
+ decay: number;
+ gain: number;
+ delay?: number;
+}
+
+export class AudioSystem {
+ private ctx: AudioContext | null = null;
+ private master: GainNode | null = null;
+ private noiseBuffer: AudioBuffer | null = null;
+ private drone: Drone | null = null;
+ private enabled = true;
+ private volume = 1;
+ private ambienceKey: string | null = null;
+
+ /** Call once at boot; the context is created on the first user gesture. */
+ attach(): void {
+ if (typeof window === 'undefined') return;
+ const init = (): void => {
+ this.initContext();
+ window.removeEventListener('pointerdown', init);
+ window.removeEventListener('keydown', init);
+ };
+ window.addEventListener('pointerdown', init);
+ window.addEventListener('keydown', init);
+ }
+
+ /** Sound on/off — immediate, and the context is suspended while off. */
+ setEnabled(on: boolean): void {
+ this.enabled = on;
+ if (!this.ctx || !this.master) return;
+ this.applyMasterGain();
+ if (on) void this.ctx.resume();
+ else void this.ctx.suspend();
+ }
+
+ /** Master volume, 0..1. */
+ setVolume(volume: number): void {
+ this.volume = Math.min(1, Math.max(0, volume));
+ this.applyMasterGain();
+ }
+
+ /** Ambient drone for a region (or 'title'); glides over ~1s on change. */
+ setAmbience(key: string): void {
+ if (key === this.ambienceKey) return;
+ this.ambienceKey = key;
+ const ctx = this.ctx;
+ if (!ctx || !this.master) return; // replayed by initContext()
+ const tuning = AMBIENCES[key] ?? FALLBACK_AMBIENCE;
+ const drone = (this.drone ??= this.buildDrone(tuning));
+ const t = ctx.currentTime;
+ const settle = DRONE_CROSSFADE_S / 3; // setTargetAtTime tau: ~settled in 3τ
+ drone.oscA.frequency.setTargetAtTime(tuning.base, t, settle);
+ drone.oscB.frequency.setTargetAtTime(tuning.base * 1.007, t, settle);
+ drone.windFilter.frequency.setTargetAtTime(tuning.wind, t, settle);
+ // Dip, then swell back — the world holds its breath between places.
+ drone.gain.gain.setTargetAtTime(DRONE_LEVEL * 0.2, t, settle * 0.6);
+ drone.gain.gain.setTargetAtTime(DRONE_LEVEL, t + DRONE_CROSSFADE_S * 0.5, settle);
+ }
+
+ // ---- combat --------------------------------------------------------------
+
+ /** Light hit: short filtered noise burst + low thump. */
+ hitLight(): void {
+ this.noise({ filterType: 'bandpass', freq: 1800, q: 0.8, decay: 0.09, gain: 0.22 });
+ this.tone({ freq: 170, freqEnd: 90, decay: 0.08, gain: 0.14 });
+ }
+
+ /** Heavy hit: lower, longer noise body + dropping sub thump. */
+ hitHeavy(): void {
+ this.noise({ filterType: 'lowpass', freq: 750, decay: 0.26, gain: 0.3 });
+ this.tone({ freq: 95, freqEnd: 52, decay: 0.3, gain: 0.26 });
+ }
+
+ /** Heal: two soft ascending bells (C5 -> E5). */
+ heal(): void {
+ this.bell(523.25, { gain: 0.1, decay: 0.9 });
+ this.bell(659.25, { gain: 0.1, decay: 1.1, delay: 0.12 });
+ }
+
+ /** Death knell: a single deep bell with a very long decay. */
+ deathKnell(): void {
+ this.bell(110, { gain: 0.22, decay: 2.4 });
+ }
+
+ /** Lower the ambient drone (death overlay) and bring it back. */
+ duckAmbience(ducked: boolean): void {
+ if (!this.ctx || !this.drone) return;
+ this.drone.gain.gain.setTargetAtTime(
+ ducked ? DRONE_LEVEL * 0.25 : DRONE_LEVEL,
+ this.ctx.currentTime,
+ 0.4,
+ );
+ }
+
+ /** Player hurt: low noise thud + a dissonant minor second. */
+ playerHurt(): void {
+ this.noise({ filterType: 'lowpass', freq: 420, decay: 0.22, gain: 0.28 });
+ this.tone({ freq: 110, decay: 0.3, gain: 0.14 });
+ this.tone({ freq: 116.54, decay: 0.3, gain: 0.12 });
+ }
+
+ /** Calm / non-violent success: resolved major third (C4 + E4). */
+ calm(): void {
+ this.tone({ freq: 261.63, attack: 0.06, decay: 1.2, gain: 0.1 });
+ this.tone({ freq: 329.63, attack: 0.06, decay: 1.4, gain: 0.09, delay: 0.05 });
+ }
+
+ /** Condition applied: short high whisper of filtered noise. */
+ condition(): void {
+ this.noise({ filterType: 'highpass', freq: 4200, decay: 0.12, gain: 0.09 });
+ this.tone({ freq: 780, freqEnd: 520, decay: 0.12, gain: 0.05 });
+ }
+
+ // ---- UI ------------------------------------------------------------------
+
+ /** Dialogue advance: a very soft, very short bell. */
+ dialogueAdvance(): void {
+ this.bell(880, { gain: 0.045, decay: 0.35 });
+ }
+
+ /** Choice selected: a dry click. */
+ uiSelect(): void {
+ this.noise({ filterType: 'bandpass', freq: 2600, q: 1.4, decay: 0.03, gain: 0.12 });
+ this.tone({ freq: 1250, decay: 0.03, gain: 0.05 });
+ }
+
+ /** Panel open/close: two short tones, rising to open, falling to close. */
+ panel(open: boolean): void {
+ const first = open ? 392 : 493.88;
+ const second = open ? 493.88 : 392;
+ this.tone({ freq: first, decay: 0.09, gain: 0.08 });
+ this.tone({ freq: second, decay: 0.12, gain: 0.08, delay: 0.07 });
+ }
+
+ /** Toast: a soft tick. */
+ toast(): void {
+ this.tone({ freq: 1567.98, decay: 0.06, gain: 0.04 });
+ }
+
+ /** Name Echo / narration: ethereal slow-attack chord (A3 + C#4 + E4). */
+ echo(): void {
+ const notes = [220, 277.18, 329.63];
+ notes.forEach((freq, i) => {
+ this.tone({ freq, attack: 0.9, decay: 2.8, gain: 0.045, delay: i * 0.15 });
+ this.tone({ freq: freq * 1.003, attack: 0.9, decay: 2.8, gain: 0.03, delay: i * 0.15 });
+ });
+ }
+
+ /** Footstep: a barely-audible percussive tick. */
+ footstep(): void {
+ this.noise({ filterType: 'lowpass', freq: 900, decay: 0.035, gain: 0.045 });
+ }
+
+ // ---- internals -----------------------------------------------------------
+
+ private initContext(): void {
+ if (this.ctx || typeof window === 'undefined') return;
+ const Ctor =
+ window.AudioContext ??
+ (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctor) return;
+ let ctx: AudioContext;
+ try {
+ ctx = new Ctor();
+ } catch {
+ return; // audio unavailable — stay silent
+ }
+ this.ctx = ctx;
+ this.master = ctx.createGain();
+ this.master.connect(ctx.destination);
+ this.applyMasterGain();
+ if (this.enabled) void ctx.resume();
+ else void ctx.suspend();
+ if (this.ambienceKey) {
+ const key = this.ambienceKey;
+ this.ambienceKey = null; // force setAmbience to apply
+ this.setAmbience(key);
+ }
+ }
+
+ private applyMasterGain(): void {
+ if (!this.ctx || !this.master) return;
+ const level = this.enabled ? MASTER_LEVEL * this.volume : 0;
+ this.master.gain.setTargetAtTime(level, this.ctx.currentTime, 0.03);
+ }
+
+ /** Persistent region drone: two detuned sines + wind noise with a slow LFO. */
+ private buildDrone(tuning: Ambience): Drone {
+ const ctx = this.ctx as AudioContext;
+ const gain = ctx.createGain();
+ gain.gain.value = 0;
+ gain.connect(this.master as GainNode);
+ const oscA = ctx.createOscillator();
+ oscA.type = 'sine';
+ oscA.frequency.value = tuning.base;
+ const oscB = ctx.createOscillator();
+ oscB.type = 'sine';
+ oscB.frequency.value = tuning.base * 1.007; // slow beating against oscA
+ const oscGain = ctx.createGain();
+ oscGain.gain.value = 0.5;
+ oscA.connect(oscGain);
+ oscB.connect(oscGain);
+ oscGain.connect(gain);
+ // Wind: looped noise through a bandpass, breathing via a slow LFO.
+ const wind = ctx.createBufferSource();
+ wind.buffer = this.getNoiseBuffer();
+ wind.loop = true;
+ const windFilter = ctx.createBiquadFilter();
+ windFilter.type = 'bandpass';
+ windFilter.frequency.value = tuning.wind;
+ windFilter.Q.value = 0.6;
+ const windGain = ctx.createGain();
+ windGain.gain.value = 0.35;
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.value = 0.09;
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.value = 0.18;
+ lfo.connect(lfoGain);
+ lfoGain.connect(windGain.gain);
+ wind.connect(windFilter);
+ windFilter.connect(windGain);
+ windGain.connect(gain);
+ oscA.start();
+ oscB.start();
+ wind.start();
+ lfo.start();
+ // Fade in slowly — a drone should be noticed only when it stops.
+ gain.gain.setTargetAtTime(DRONE_LEVEL, ctx.currentTime, 1.5);
+ return { oscA, oscB, windFilter, gain };
+ }
+
+ /** One shared second of white noise, created on first use. */
+ private getNoiseBuffer(): AudioBuffer {
+ const ctx = this.ctx as AudioContext;
+ if (!this.noiseBuffer) {
+ const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
+ this.noiseBuffer = buffer;
+ }
+ return this.noiseBuffer;
+ }
+
+ /** A struck bell: sine fundamental + one inharmonic partial, long decay. */
+ private bell(freq: number, opts: { gain?: number; decay?: number; delay?: number } = {}): void {
+ const gain = opts.gain ?? 0.18;
+ const decay = opts.decay ?? 1.2;
+ this.tone({ freq, gain, decay, delay: opts.delay });
+ this.tone({ freq: freq * 2.76, gain: gain * 0.3, decay: decay * 0.5, delay: opts.delay });
+ }
+
+ private tone(opts: ToneOpts): void {
+ const ctx = this.ctx;
+ const master = this.master;
+ if (!ctx || !master || !this.enabled) return;
+ const t = ctx.currentTime + (opts.delay ?? 0);
+ const attack = opts.attack ?? 0.005;
+ const decay = opts.decay ?? 0.3;
+ const osc = ctx.createOscillator();
+ osc.type = opts.type ?? 'sine';
+ osc.frequency.setValueAtTime(opts.freq, t);
+ if (opts.freqEnd !== undefined) {
+ osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, t + attack + decay);
+ }
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(0, t);
+ gain.gain.linearRampToValueAtTime(opts.gain ?? 0.2, t + attack);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
+ osc.connect(gain);
+ gain.connect(master);
+ osc.start(t);
+ osc.stop(t + attack + decay + 0.05);
+ }
+
+ private noise(opts: NoiseOpts): void {
+ const ctx = this.ctx;
+ const master = this.master;
+ if (!ctx || !master || !this.enabled) return;
+ const t = ctx.currentTime + (opts.delay ?? 0);
+ const attack = opts.attack ?? 0.003;
+ const src = ctx.createBufferSource();
+ src.buffer = this.getNoiseBuffer();
+ const filter = ctx.createBiquadFilter();
+ filter.type = opts.filterType;
+ filter.frequency.value = opts.freq;
+ filter.Q.value = opts.q ?? 1;
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(0, t);
+ gain.gain.linearRampToValueAtTime(opts.gain, t + attack);
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + opts.decay);
+ src.connect(filter);
+ filter.connect(gain);
+ gain.connect(master);
+ src.start(t);
+ src.stop(t + attack + opts.decay + 0.05);
+ }
+}
@@ -56,6 +56,22 @@ ); return allyAdjacent ? 2 : 0;
}
+/**
+ * The condition an enemy's attack inflicts right now. Pack hunters (wolves)
+ * only bleed their prey while flanking — alone, the bite is just a bite.
+ */
+export function inflictedCondition(
+ attacker: ActorState,
+ defender: ActorState,
+ all: ActorState[],
+): { id: ConditionId; duration: number } | undefined {
+ if (attacker.kind !== 'enemy') return undefined;
+ const def = enemyDef(attacker.defId);
+ if (!def.appliesCondition) return undefined;
+ if (def.packTactics && flankingBonus(attacker, defender, all) === 0) return undefined;
+ return def.appliesCondition;
+}
+
export function performAttack(
attacker: ActorState,
defender: ActorState,
@@ -69,6 +85,10 @@ const damage = Math.round( computeDamage(attack, defense, options.mult ?? 1, rng) * incomingDamageMult(defender),
);
defender.hp = Math.max(0, defender.hp - damage);
+ // Being struck steadies the mind: the player gains 1 focus per attack taken.
+ if (defender.kind === 'player') {
+ defender.focus = Math.min(defender.maxFocus, defender.focus + 1);
+ }
const brokenConditions = onActorHit(defender);
if (defender.hp === 0) {
defender.alive = false;
@@ -136,6 +156,8 @@ if (!rng.chance(chance)) return false; target.faction = 'neutral';
target.aggroed = false;
removeCondition(target, 'afraid');
+ // Calming another steadies the mind: +1 focus.
+ actor.focus = Math.min(actor.maxFocus, actor.focus + 1);
return true;
}
@@ -1,9 +1,19 @@ import type { GridPos } from '../core/types';
+export type ShakeIntensity = 'sm' | 'md' | 'lg';
+
export type EffectRequest =
| { kind: 'impact'; pos: GridPos; glyph: string }
| { kind: 'floatingText'; pos: GridPos; text: string; tone: 'damage' | 'heal' | 'word' }
- | { kind: 'shake' };
+ | {
+ kind: 'flash';
+ actorId: string;
+ pos: GridPos;
+ tone: 'damage' | 'heal';
+ /** Attacker position, when known — the target recoils away from it. */
+ from: GridPos | null;
+ }
+ | { kind: 'shake'; intensity: ShakeIntensity };
export type EffectHandler = (request: EffectRequest) => void;
@@ -32,7 +42,17 @@ word(pos: GridPos, text: string): void { this.handler?.({ kind: 'floatingText', pos, text, tone: 'word' });
}
- shake(): void {
- this.handler?.({ kind: 'shake' });
+ /** Brief hit flash (+ small recoil away from `from`) on the struck actor. */
+ hitFlash(actorId: string, pos: GridPos, from: GridPos | null): void {
+ this.handler?.({ kind: 'flash', actorId, pos, tone: 'damage', from });
+ }
+
+ /** Soft golden flash on the healed actor. */
+ healFlash(actorId: string, pos: GridPos): void {
+ this.handler?.({ kind: 'flash', actorId, pos, tone: 'heal', from: null });
+ }
+
+ shake(intensity: ShakeIntensity = 'md'): void {
+ this.handler?.({ kind: 'shake', intensity });
}
}
@@ -0,0 +1,30 @@+import type { GameState } from '../core/GameState';
+import { DIALOGUES } from '../data/dialogues';
+import { SHARDS } from '../data/shards';
+
+/**
+ * "Chapter complete" is honest about scope: the current content awards only
+ * some of the four Name Shards. Scanning dialogue effects for what can be
+ * earned keeps the threshold correct as new content ships — no fake endings.
+ */
+export function obtainableShardIds(): string[] {
+ const found = new Set<string>();
+ for (const tree of Object.values(DIALOGUES)) {
+ for (const node of Object.values(tree.nodes)) {
+ const effects = [
+ ...(node.effects ?? []),
+ ...(node.choices ?? []).flatMap((choice) => choice.effects ?? []),
+ ];
+ for (const effect of effects) {
+ if (effect.type === 'gainShard' && SHARDS[effect.shardId]) found.add(effect.shardId);
+ }
+ }
+ }
+ return [...found];
+}
+
+/** True when the player holds every shard the current content can award. */
+export function chapterComplete(state: GameState): boolean {
+ const obtainable = obtainableShardIds();
+ return obtainable.length > 0 && obtainable.every((id) => state.shards.includes(id));
+}
@@ -12,8 +12,9 @@ import { hasCondition } from './ConditionSystem';
export type AiIntent =
| { type: 'move'; path: GridPos[] }
- | { type: 'attack'; targetId: string }
+ | { type: 'attack'; targetId: string; mult?: number }
| { type: 'rangedAttack'; targetId: string }
+ | { type: 'push'; targetId: string }
| { type: 'guard' }
| { type: 'wait' };
@@ -173,7 +174,12 @@ ): AiIntent { if (enemy.aggroed || manhattan(enemy.pos, ctx.player.pos) <= aggroRange) {
enemy.aggroed = true;
const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
- if (adjacent) return adjacent;
+ if (adjacent) {
+ // Every third blow is a heavy swing; the fixed cadence is the tell.
+ enemy.routineStep = (enemy.routineStep ?? 0) + 1;
+ const mult = enemy.routineStep % 3 === 0 ? 1.5 : undefined;
+ return { type: 'attack', targetId: ctx.player.id, mult };
+ }
const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
return path ? { type: 'move', path } : { type: 'wait' };
}
@@ -241,7 +247,8 @@ return { type: 'wait' }; // winding down — its tell }
// Crown soldier: disciplined advance; when hurt he alternates guard/advance
-// instead of flailing — order, even in retreat.
+// instead of flailing — order, even in retreat. Pressed while hurt, every
+// other contact is a shove to reopen distance.
function crownSoldierAction(
enemy: ActorState,
moveRange: number,
@@ -249,9 +256,15 @@ attackRange: number, ctx: AiContext,
occupancy: Occupancy,
): AiIntent {
+ const hurt = enemy.hp < enemy.maxHp / 2;
const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
- if (adjacent) return adjacent;
- const hurt = enemy.hp < enemy.maxHp / 2;
+ if (adjacent) {
+ if (hurt) {
+ enemy.routineStep = (enemy.routineStep ?? 0) + 1;
+ if (enemy.routineStep % 2 === 0) return { type: 'push', targetId: ctx.player.id };
+ }
+ return adjacent;
+ }
if (hurt) {
enemy.routineStep = (enemy.routineStep ?? 0) + 1;
if (enemy.routineStep % 2 === 0) return { type: 'guard' };
@@ -0,0 +1,13 @@+/**
+ * One-time onboarding hints, persisted as world flags: returns the text to
+ * show the first time, null afterwards. No framework — flags + toast.
+ */
+export function hintOnce(
+ flags: Record<string, boolean>,
+ flagId: string,
+ text: string,
+): string | null {
+ if (flags[flagId]) return null;
+ flags[flagId] = true;
+ return text;
+}
@@ -7,7 +7,7 @@ import { tileAt } from '../world/MapDefinition'; import type { Renderer } from '../rendering/Renderer';
import type { AnimationQueue } from '../rendering/AnimationQueue';
import type { EffectSystem } from './EffectSystem';
-import { applyCondition, removeCondition } from './ConditionSystem';
+import { applyCondition } from './ConditionSystem';
export interface MovementDeps {
getState: () => GameState;
@@ -74,10 +74,6 @@ if (applyCondition(actor, 'unstable')) { this.deps.effects.word(actor.pos, 'Unstable');
this.deps.renderer.refreshActor(actor);
}
- }
- if (hazard === 'water' && removeCondition(actor, 'burning')) {
- this.deps.effects.word(actor.pos, 'Extinguished');
- this.deps.renderer.refreshActor(actor);
}
}
}
@@ -1,4 +1,5 @@ import type { GameState } from '../core/GameState';
+import type { InputAction } from '../core/InputManager';
import { el, clear } from '../utils/dom';
import { conditionDef } from '../data/conditions';
@@ -29,6 +30,8 @@ private readonly actionBar: HTMLElement; private readonly toastBox: HTMLElement;
private toastTimer: number | null = null;
private actionHandler: ((actionId: string) => void) | null = null;
+ private touchHandler: ((action: InputAction) => void) | null = null;
+ private readonly touchInteract: HTMLButtonElement;
constructor(parent: HTMLElement) {
this.root = el('div', 'hud');
@@ -54,10 +57,35 @@ this.actionBar = el('div', 'hud__actions'); this.toastBox = el('div', 'hud__toast');
this.toastBox.setAttribute('role', 'status');
- this.root.append(status, this.conditionsRow, context, this.turnOrderRow, this.actionBar, this.toastBox);
+ // Touch controls: hidden unless the pointer is coarse (CSS media query).
+ const touchBar = el('div', 'hud__touch');
+ this.touchInteract = this.touchButton('Act', () => this.touchHandler?.({ type: 'interact' }));
+ touchBar.append(
+ this.touchInteract,
+ this.touchButton('Bag', () => this.touchHandler?.({ type: 'openPanel', panel: 'inventory' })),
+ this.touchButton('Journal', () => this.touchHandler?.({ type: 'openPanel', panel: 'journal' })),
+ );
+
+ this.root.append(status, this.conditionsRow, context, this.turnOrderRow, this.actionBar, this.toastBox, touchBar);
parent.append(this.root);
}
+ private touchButton(label: string, onClick: () => void): HTMLButtonElement {
+ const button = el('button', 'hud__touch-button', label) as HTMLButtonElement;
+ button.type = 'button';
+ button.addEventListener('click', onClick);
+ return button;
+ }
+
+ onTouch(handler: (action: InputAction) => void): void {
+ this.touchHandler = handler;
+ }
+
+ /** The Act button only lights up when there is something to interact with. */
+ setTouchInteractVisible(visible: boolean): void {
+ this.touchInteract.classList.toggle('hud__touch-button--dim', !visible);
+ }
+
onAction(handler: (actionId: string) => void): void {
this.actionHandler = handler;
}
@@ -75,7 +103,11 @@ clear(this.conditionsRow); for (const active of player.conditions) {
const def = conditionDef(active.id);
const chip = el('span', 'condition-chip glyph', def.glyph);
- chip.title = `${def.name} (${active.remaining}) — ${def.tooltip}`;
+ const label = `${def.name} (${active.remaining}) — ${def.tooltip}`;
+ chip.title = label;
+ chip.setAttribute('aria-label', label);
+ chip.dataset.tip = label;
+ chip.tabIndex = 0;
this.conditionsRow.append(chip);
}
}
@@ -113,13 +145,13 @@ this.actionBar.append(button); });
}
- toast(text: string): void {
+ toast(text: string, durationMs = 3200): void {
this.toastBox.textContent = text;
this.toastBox.classList.add('hud__toast--visible');
if (this.toastTimer !== null) window.clearTimeout(this.toastTimer);
this.toastTimer = window.setTimeout(() => {
this.toastBox.classList.remove('hud__toast--visible');
- }, 3200);
+ }, durationMs);
}
}
@@ -70,6 +70,9 @@ ): HTMLElement { const def = itemDef(itemId);
const row = el('div', 'inventory__row');
row.title = def.description;
+ row.setAttribute('aria-label', `${def.name} — ${def.description}`);
+ row.dataset.tip = def.description;
+ row.tabIndex = 0;
row.append(el('span', 'inventory__glyph glyph', def.glyph));
const name = el('span', 'inventory__name', def.name);
row.append(name);
@@ -100,6 +100,10 @@ const form = el('div', 'settings-form'); form.append(
toggleRow('Reduced motion', draft.reducedMotion, (v) => (draft.reducedMotion = v)),
toggleRow('Screen shake', draft.screenShake, (v) => (draft.screenShake = v)),
+ toggleRow('Sound', draft.sound, (v) => (draft.sound = v)),
+ sliderRow('Sound volume', draft.soundVolume, [0.25, 0.5, 0.75, 1], ['Quiet', 'Soft', 'Normal', 'Full'], (v) => {
+ draft.soundVolume = v;
+ }),
sliderRow('Animation speed', draft.animationSpeed, [0.5, 1, 2], ['Fast', 'Normal', 'Slow'], (v) => {
draft.animationSpeed = v;
}),
@@ -184,6 +188,77 @@ element, show: () => element.classList.add('pause-overlay--visible'),
hide: () => element.classList.remove('pause-overlay--visible'),
visible: () => element.classList.contains('pause-overlay--visible'),
+ };
+}
+
+export interface StoryOverlay {
+ element: HTMLElement;
+ show: () => void;
+ hide: () => void;
+ visible: () => boolean;
+}
+
+/** Death: a short verse over ink, then the choice to rise. */
+export function buildDeathOverlay(onRise: () => void): StoryOverlay {
+ const element = el('div', 'story-overlay story-overlay--death');
+ element.setAttribute('aria-label', 'You died');
+ const panel = el('div', 'story-overlay__panel');
+ panel.append(
+ el('p', 'story-overlay__line', 'The grave remembers you.'),
+ el('p', 'story-overlay__line', 'But it cannot keep what it cannot name.'),
+ );
+ const nav = el('nav', 'menu__nav');
+ nav.append(menuButton('Rise again', onRise));
+ panel.append(nav);
+ element.append(panel);
+ return {
+ element,
+ show: () => element.classList.add('story-overlay--visible'),
+ hide: () => element.classList.remove('story-overlay--visible'),
+ visible: () => element.classList.contains('story-overlay--visible'),
+ };
+}
+
+export interface EndingOverlay extends StoryOverlay {
+ showFor: (shards: { glyph: string; name: string }[]) => void;
+}
+
+/**
+ * Chapter complete: the current content's shards, carried. Not an ending —
+ * the world is not done forgetting.
+ */
+export function buildEndingOverlay(callbacks: {
+ onContinue: () => void;
+ onTitle: () => void;
+}): EndingOverlay {
+ const element = el('div', 'story-overlay story-overlay--ending');
+ element.setAttribute('aria-label', 'Chapter complete');
+ const panel = el('div', 'story-overlay__panel');
+ const shardRow = el('div', 'story-overlay__shards');
+ panel.append(
+ el('h2', 'menu__heading', 'What the road gave back'),
+ shardRow,
+ el('p', 'story-overlay__line', 'The shards you carry hum like bells that almost remember their sound.'),
+ el('p', 'story-overlay__line', 'The world is not done forgetting. You are not done walking.'),
+ );
+ const nav = el('nav', 'menu__nav menu__nav--row');
+ nav.append(menuButton('Keep walking', callbacks.onContinue), menuButton('Return to title', callbacks.onTitle));
+ panel.append(nav);
+ element.append(panel);
+ return {
+ element,
+ show: () => element.classList.add('story-overlay--visible'),
+ showFor: (shards) => {
+ clear(shardRow);
+ for (const shard of shards) {
+ const entry = el('span', 'story-overlay__shard');
+ entry.append(el('span', 'glyph', shard.glyph), el('span', 'story-overlay__shard-name', shard.name));
+ shardRow.append(entry);
+ }
+ element.classList.add('story-overlay--visible');
+ },
+ hide: () => element.classList.remove('story-overlay--visible'),
+ visible: () => element.classList.contains('story-overlay--visible'),
};
}
@@ -21,7 +21,7 @@ | 'water' | 'hut'
| 'unstable';
-export type HazardKind = 'water' | 'unstable' | 'fire';
+export type HazardKind = 'unstable' | 'fire';
export interface TileDef {
terrain: TerrainId;
@@ -75,7 +75,7 @@ terrain: 'water', walkable: false,
blocksSight: false,
glyph: glyphOf(GLYPH_IDS.water),
- hazard: 'water',
+ hazard: null, // unwalkable, so nothing ever stands on it to be extinguished
label: 'Black water',
},
h: {
@@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest';
import { Rng } from '../src/core/Rng';
-import { computeDamage, performAttack, performCalm, flankingBonus } from '../src/systems/CombatSystem';
+import { computeDamage, performAttack, performCalm, flankingBonus, inflictedCondition } from '../src/systems/CombatSystem';
import { applyCondition } from '../src/systems/ConditionSystem';
import { createEnemyState, createPlayerState } from '../src/entities/EntityFactory';
@@ -54,15 +54,6 @@ const vsUnstable = performAttack(player, wolfB, new Rng(3), [player, wolfB], {}); expect(vsUnstable.damage).toBeGreaterThan(normal.damage);
});
- it('breaks frozen when the target is struck', () => {
- const player = createPlayerState('neutral', { x: 0, y: 0 });
- const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
- applyCondition(wolf, 'frozen');
- const outcome = performAttack(player, wolf, new Rng(2), [player, wolf], {});
- expect(outcome.brokenConditions).toContain('frozen');
- expect(wolf.conditions.some((c) => c.id === 'frozen')).toBe(false);
- });
-
it('applies on-hit conditions to living targets', () => {
const player = createPlayerState('neutral', { x: 0, y: 0 });
const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
@@ -72,6 +63,68 @@ }); expect(outcome.conditionsApplied).toContain('staggered');
expect(wolf.conditions.some((c) => c.id === 'staggered')).toBe(true);
});
+
+ it('grants the player 1 focus when struck by an attack', () => {
+ const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+ const player = createPlayerState('neutral', { x: 1, y: 0 });
+ player.focus = 2;
+ performAttack(wolf, player, new Rng(4), [wolf, player], {});
+ expect(player.focus).toBe(3);
+ });
+
+ it('caps focus gain from being hit at max focus', () => {
+ const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+ const player = createPlayerState('neutral', { x: 1, y: 0 });
+ player.focus = player.maxFocus;
+ performAttack(wolf, player, new Rng(4), [wolf, player], {});
+ expect(player.focus).toBe(player.maxFocus);
+ });
+
+ it('does not grant focus to enemies when they are hit', () => {
+ const player = createPlayerState('neutral', { x: 0, y: 0 });
+ const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+ wolf.focus = 0;
+ performAttack(player, wolf, new Rng(4), [player, wolf], {});
+ expect(wolf.focus).toBe(0);
+ });
+});
+
+describe('inflictedCondition', () => {
+ it('wolf bite bleeds only while flanking in a pack', () => {
+ const wolfA = createEnemyState('ashWolf', { x: 0, y: 0 });
+ const player = createPlayerState('neutral', { x: 1, y: 0 });
+ expect(inflictedCondition(wolfA, player, [wolfA, player])).toBeUndefined();
+ const wolfB = createEnemyState('ashWolf', { x: 2, y: 0 });
+ expect(inflictedCondition(wolfA, player, [wolfA, wolfB, player])).toEqual({
+ id: 'bleeding',
+ duration: 3,
+ });
+ });
+
+ it('archer arrows destabilize; rust machine fists burn; the lurker still drowns memory', () => {
+ const player = createPlayerState('neutral', { x: 1, y: 0 });
+ const archer = createEnemyState('crownArcher', { x: 0, y: 0 });
+ expect(inflictedCondition(archer, player, [archer, player])).toEqual({
+ id: 'unstable',
+ duration: 2,
+ });
+ const machine = createEnemyState('rustMachine', { x: 0, y: 0 });
+ expect(inflictedCondition(machine, player, [machine, player])).toEqual({
+ id: 'burning',
+ duration: 2,
+ });
+ const lurker = createEnemyState('mireLurker', { x: 0, y: 0 });
+ expect(inflictedCondition(lurker, player, [lurker, player])).toEqual({
+ id: 'forgotten',
+ duration: 2,
+ });
+ });
+
+ it('non-enemies never inflict conditions this way', () => {
+ const player = createPlayerState('neutral', { x: 0, y: 0 });
+ const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+ expect(inflictedCondition(player, wolf, [player, wolf])).toBeUndefined();
+ });
});
describe('flankingBonus', () => {
@@ -107,6 +160,23 @@ expect(freshWolf.aggroed).toBe(false); }
}
expect(calmed).toBe(true);
+ });
+
+ it('grants the player 1 focus on a successful calm, capped at max', () => {
+ const player = createPlayerState('neutral', { x: 0, y: 0 });
+ player.focus = player.maxFocus - 1;
+ let calmed = false;
+ for (let seed = 0; seed < 50 && !calmed; seed++) {
+ const freshWolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+ calmed = performCalm(player, freshWolf, 3, new Rng(seed));
+ }
+ expect(calmed).toBe(true);
+ expect(player.focus).toBe(player.maxFocus);
+ // A failed attempt grants nothing.
+ player.focus = 2;
+ const machine = createEnemyState('rustMachine', { x: 1, y: 0 });
+ expect(performCalm(player, machine, 10, new Rng(1))).toBe(false);
+ expect(player.focus).toBe(2);
});
it('never calms non-calmable enemies or distant targets', () => {
@@ -16,10 +16,10 @@ it('every binding condition has effect, glyph, tooltip and duration', () => { const required = [
'bleeding',
'burning',
- 'frozen',
'staggered',
'afraid',
'guarded',
+ 'guarding',
'silenced',
'forgotten',
'remembered',
@@ -65,14 +65,6 @@ expect(tick.damage).toBe(3); expect(wolf.alive).toBe(false);
});
- it('frozen prevents the action and sets speed to 0', () => {
- const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
- applyCondition(wolf, 'frozen');
- expect(speedOf(wolf)).toBe(0);
- const tick = tickConditions(wolf);
- expect(tick.preventsAction).toBe(true);
- });
-
it('staggered halves speed', () => {
const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
applyCondition(wolf, 'staggered');
@@ -92,6 +84,15 @@ expect(tick.focusDelta).toBe(1); expect(player.focus).toBe(3);
});
+ it('guarding raises defense like guarded and expires on the next turn', () => {
+ const player = createPlayerState('neutral', { x: 0, y: 0 });
+ applyCondition(player, 'guarding', 1);
+ expect(defenseOf(player)).toBeCloseTo(player.stats.defense * 1.5);
+ const tick = tickConditions(player);
+ expect(tick.expired).toContain('guarding');
+ expect(defenseOf(player)).toBeCloseTo(player.stats.defense);
+ });
+
it('silenced blocks abilities', () => {
const player = createPlayerState('neutral', { x: 0, y: 0 });
applyCondition(player, 'silenced');
@@ -113,6 +114,6 @@ applyCondition(player, 'burning'); applyCondition(player, 'bleeding');
expect(removeCondition(player, 'burning')).toBe(true);
expect(player.conditions.map((c) => c.id)).toEqual(['bleeding']);
- expect(removeCondition(player, 'frozen')).toBe(false);
+ expect(removeCondition(player, 'staggered')).toBe(false);
});
});
@@ -4,6 +4,8 @@ import { GRAVE_CLEARING } from '../src/data/maps/graveClearing'; import { OLD_ROAD } from '../src/data/maps/oldRoad';
import { validateContent } from '../src/utils/validation';
import { tileAt } from '../src/world/MapDefinition';
+import { ENEMY_IDS } from '../src/data/enemies';
+import { ITEM_IDS } from '../src/data/items';
// Importing a map module runs defineMap, which throws on malformed rows —
// so this suite failing at import time is itself the signal.
@@ -39,5 +41,15 @@ const grave = GRAVE_CLEARING.interactables.find((i) => i.id === 'yourGrave'); const tree = GRAVE_CLEARING.interactables.find((i) => i.id === 'deadTree');
expect(tileAt(GRAVE_CLEARING, grave!.pos)?.terrain).toBe('grave');
expect(tileAt(GRAVE_CLEARING, tree!.pos)?.terrain).toBe('deadTree');
+ });
+
+ it('formerly orphaned content is reachable: rust machine placed, bow and letter obtainable', () => {
+ const spawns = ALL_MAPS.flatMap((m) => m.enemies);
+ expect(spawns.some((s) => s.defId === ENEMY_IDS.rustMachine)).toBe(true);
+ const loot = ALL_MAPS.flatMap((m) => m.interactables)
+ .filter((i) => i.action.type === 'item')
+ .map((i) => (i.action as { itemId: string }).itemId);
+ expect(loot).toContain(ITEM_IDS.huntingBow);
+ expect(loot).toContain(ITEM_IDS.letterToTheDrowned);
});
});
@@ -70,6 +70,33 @@ expect(intent.type).toBe('wait'); expect(soldier.patrolIndex).toBe(1);
});
+ it('hollow soldier telegraphs a heavy swing every third attack', () => {
+ const player = createPlayerState('neutral', { x: 4, y: 4 });
+ const soldier = createEnemyState('hollowSoldier', { x: 4, y: 3 });
+ const mults = [0, 1, 2, 3].map(() => {
+ const intent = decideEnemyAction(soldier, ctx(player, [soldier]));
+ return intent.type === 'attack' ? intent.mult : null;
+ });
+ expect(mults).toEqual([undefined, undefined, 1.5, undefined]);
+ });
+
+ it('crown soldier alternates attack and shove while hurt and pressed', () => {
+ const player = createPlayerState('neutral', { x: 4, y: 4 });
+ const soldier = createEnemyState('crownSoldier', { x: 4, y: 3 });
+ soldier.hp = 5; // below half of 18
+ const first = decideEnemyAction(soldier, ctx(player, [soldier]));
+ const second = decideEnemyAction(soldier, ctx(player, [soldier]));
+ expect(first.type).toBe('attack');
+ expect(second).toEqual({ type: 'push', targetId: 'player' });
+ });
+
+ it('crown soldier without wounds never shoves', () => {
+ const player = createPlayerState('neutral', { x: 4, y: 4 });
+ const soldier = createEnemyState('crownSoldier', { x: 4, y: 3 });
+ const intent = decideEnemyAction(soldier, ctx(player, [soldier]));
+ expect(intent.type).toBe('attack');
+ });
+
it('archer fires from range with line of sight, closes in when blind', () => {
const player = createPlayerState('neutral', { x: 4, y: 4 });
const archer = createEnemyState('crownArcher', { x: 4, y: 1 });
@@ -0,0 +1,53 @@+import { describe, it, expect } from 'vitest';
+import { hintOnce } from '../src/systems/HintSystem';
+import { chapterComplete, obtainableShardIds } from '../src/systems/EndingSystem';
+import { createNewGameState } from '../src/entities/EntityFactory';
+import { SHARD_IDS, TOTAL_SHARDS } from '../src/data/shards';
+
+function freshState() {
+ return createNewGameState({
+ variantId: 'neutral',
+ mapId: 'graveClearing',
+ region: 'The Nameless Edge',
+ objective: 'Wake.',
+ playerSpawn: { x: 6, y: 3 },
+ seed: 1,
+ });
+}
+
+describe('hintOnce', () => {
+ it('shows the text once, then never again, and persists as a flag', () => {
+ const flags: Record<string, boolean> = {};
+ expect(hintOnce(flags, 'hintCombat', 'Fight smart.')).toBe('Fight smart.');
+ expect(flags.hintCombat).toBe(true);
+ expect(hintOnce(flags, 'hintCombat', 'Fight smart.')).toBeNull();
+ });
+
+ it('tracks hints independently per flag', () => {
+ const flags: Record<string, boolean> = {};
+ expect(hintOnce(flags, 'a', 'A')).toBe('A');
+ expect(hintOnce(flags, 'b', 'B')).toBe('B');
+ expect(hintOnce(flags, 'a', 'A')).toBeNull();
+ });
+});
+
+describe('chapterComplete', () => {
+ it('only the shards current content can award count as obtainable', () => {
+ const obtainable = obtainableShardIds();
+ expect(obtainable).toContain(SHARD_IDS.glasswood);
+ expect(obtainable).toContain(SHARD_IDS.mire);
+ // rust and coast exist as data but no content awards them yet
+ expect(obtainable.length).toBeLessThan(TOTAL_SHARDS);
+ });
+
+ it('is false with no shards and true once every obtainable shard is carried', () => {
+ const state = freshState();
+ expect(chapterComplete(state)).toBe(false);
+ state.shards.push(SHARD_IDS.glasswood);
+ expect(chapterComplete(state)).toBe(false); // still missing the mire shard
+ for (const id of obtainableShardIds()) {
+ if (!state.shards.includes(id)) state.shards.push(id);
+ }
+ expect(chapterComplete(state)).toBe(true);
+ });
+});