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
6 changes: 6 additions & 0 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,12 @@ export class AudioManager {
// Low whoosh when a frostpeak gust kicks in.
this.noiseBurst({ dur: 0.9, vol: 0.15, freq: 260 });
break;
case 'note':
// Soft page rustle followed by a bright two-note pencil chime.
this.noiseBurst({ dur: 0.12, vol: 0.12, freq: 900 });
this.tone({ freq: 659, dur: 0.12, type: 'triangle', vol: 0.18, delay: 0.06 });
this.tone({ freq: 880, dur: 0.18, type: 'sine', vol: 0.16, delay: 0.16 });
break;
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,9 @@ export interface GameCtx {
* is persisted to the fossil codex (id = "<levelIdx>:<i>").
*/
collectFossil(x: number, y: number, id: string): void;
/**
* A field-note page was picked up: score + sparkle, and the first
* discovery is persisted to the notes codex (id = "<levelIdx>:<i>").
*/
collectNote(x: number, y: number, id: string): void;
}
137 changes: 134 additions & 3 deletions src/game.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CFG, VW, VH, TAU, FONT_STACK, DIFFICULTIES, STARS } from './config';
import type { Difficulty } from './config';
import { clamp, easeOutBack, fmtTime } from './util';
import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getSkinId, setSkinId } from './store';
import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getFoundNotes, findNote, getSkinId, setSkinId } from './store';
import type { GameStats } from './store';
import { AudioManager } from './audio';
import { Input } from './input';
Expand All @@ -20,6 +20,7 @@ import { drawDecor } from './decor';
import { Sprite, SKINS, skinUnlocked } from './sprite';
import { adaptiveFlags } from './adaptive';
import { Weather } from './weather';
import { NOTES, totalNotes as countNotes } from './lore';
import { drawPowerUpIcon, POWERUP_COLORS } from './powerup';
import type { PowerUpType } from './powerup';
import type { GameCtx } from './ctx';
Expand Down Expand Up @@ -139,6 +140,10 @@ export class Game implements GameCtx {
ghostOn: boolean = getGhostEnabled();
/** Fossil ids discovered so far (persistent meta-progress). */
fossilsFound: string[] = getFoundFossils();
/** Field-note ids discovered so far (persistent meta-progress). */
notesFound: string[] = getFoundNotes();
/** Menu sub-screen: the main menu or the field-notes codex. */
menuScreen: 'main' | 'codex' = 'main';
private ghost: GhostPlayer | null = null;
private ghostRec: GhostRecorder | null = null;
/** Cheat queue: apply max hearts once a player exists. */
Expand Down Expand Up @@ -317,6 +322,11 @@ export class Game implements GameCtx {
return LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0);
}

/** Total field notes across the hand-built levels. */
totalNotes(): number {
return countNotes();
}

startGame(): void {
this.buildLevel();
this.player = new Player(this.level!.start.x, this.level!.start.y, this);
Expand Down Expand Up @@ -444,6 +454,11 @@ export class Game implements GameCtx {
this.cycleSkin(k === 'skinNext' ? 1 : -1);
return;
}
if (k === 'codex' && this.state === 'menu') {
this.menuScreen = this.menuScreen === 'codex' ? 'main' : 'codex';
this.audio.play('ui');
return;
}
if (k === 'debug') {
this.debug = !this.debug;
return;
Expand Down Expand Up @@ -612,6 +627,25 @@ export class Game implements GameCtx {
}
}

/** Field-note pickup (GameCtx): persistent discovery + re-collectable score. */
collectNote(x: number, y: number, id: string): void {
const first = !this.notesFound.includes(id);
if (first) {
findNote(id);
this.notesFound = getFoundNotes();
}
this.addScore(CFG.score.fossil, x, y - 14);
this.audio.play('note');
if (first) {
this.addStatus('Field note found! ' + this.notesFound.length + '/' + this.totalNotes(), '#cfe6ff');
this.addShake(2);
this.burst(x, y, 22, ['#fbf6ea', '#cfe6ff', '#e7dcc2', '#fff'], 'dot', 170);
this.texts.push(new FloatingText(x, y - 34, 'NEW NOTE!', '#cfe6ff'));
} else {
this.burst(x, y, 10, ['#fbf6ea', '#cfe6ff'], 'dot', 130);
}
}

burst(x: number, y: number, n: number, colors: string[], type: ParticleType, speed: number): void {
if (this.reducedMotion) n = Math.max(1, Math.floor(n * 0.4));
for (let i = 0; i < n; i++) {
Expand Down Expand Up @@ -856,7 +890,8 @@ export class Game implements GameCtx {
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (!this.level) this.buildLevel();
if (this.state === 'menu') {
this.renderMenu(ctx);
if (this.menuScreen === 'codex') this.renderCodex(ctx);
else this.renderMenu(ctx);
ctx.restore();
return;
}
Expand Down Expand Up @@ -900,6 +935,9 @@ export class Game implements GameCtx {
for (const f of this.level!.fossils) {
if (!f.collected) f.draw(ctx, this.time);
}
for (const n of this.level!.notes) {
if (!n.collected) n.draw(ctx, this.time);
}
// Power-up capsules (enemy drops)
for (const pw of this.level!.powerups) {
if (pw.collected) continue;
Expand Down Expand Up @@ -1747,7 +1785,7 @@ export class Game implements GameCtx {
ctx.font = '600 12px ' + FONT_STACK;
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(
'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · SINCE ' + since,
'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · NOTES ' + this.notesFound.length + '/' + this.totalNotes() + ' · SINCE ' + since,
VW / 2,
205,
);
Expand Down Expand Up @@ -1976,6 +2014,14 @@ export class Game implements GameCtx {
color: '#8fa8ba',
action: () => this.toggleGhost(),
},
{
x: 202, y: 474, w: 168, h: 40, label: 'Field Notes · C',
color: '#8fa8ba',
action: () => {
this.menuScreen = 'codex';
this.audio.play('ui');
},
},
{ x: VW / 2 - 100, y: 470, w: 200, h: 48, label: 'Start Game', action: () => this.startGame() },
{
x: 648, y: 474, w: 132, h: 40, label: (this.audio.muted ? 'Sound: Off' : 'Sound: On') + ' · M',
Expand Down Expand Up @@ -2006,6 +2052,91 @@ export class Game implements GameCtx {
ctx.globalAlpha = 1;
}

/** The field-notes codex: one parchment column per level. */
renderCodex(ctx: CanvasRenderingContext2D): void {
// Scenic backdrop (same auto-pan as the menu), dimmed for reading
const camX = (Math.sin(this.time * 0.06) * 0.5 + 0.5) * 900;
this.bg.draw(ctx, camX, this.time);
ctx.fillStyle = 'rgba(8,12,22,0.8)';
ctx.fillRect(0, 0, VW, VH);
// Header
ctx.textAlign = 'center';
ctx.font = '800 34px ' + FONT_STACK;
ctx.fillStyle = '#f4ecd9';
this.drawTracked(ctx, 'FIELD NOTES', VW / 2, 50, 3, false);
ctx.font = '600 13px ' + FONT_STACK;
ctx.fillStyle = 'rgba(220,210,180,0.75)';
ctx.fillText(this.notesFound.length + '/' + this.totalNotes() + ' recovered', VW / 2, 72);
// One panel per hand-built level
const pw = 224, gap = 14, py = 92, ph = 388;
const x0 = (VW - (pw * LEVELS.length + gap * (LEVELS.length - 1))) / 2;
LEVELS.forEach((li, i) => {
const px = x0 + i * (pw + gap);
this.drawInfoPanel(ctx, px, py, pw, ph, li.subtitle);
const found = (n: number) => this.notesFound.includes(i + ':' + n);
NOTES[i].forEach((entry, n) => {
const ey = py + 62 + n * 108;
// divider above entries 2 and 3
if (n > 0) {
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + 16, ey - 10);
ctx.lineTo(px + pw - 16, ey - 10);
ctx.stroke();
}
if (found(n)) {
ctx.textAlign = 'left';
ctx.font = '800 12px ' + FONT_STACK;
ctx.fillStyle = '#ffd257';
ctx.fillText(entry.title, px + 16, ey);
ctx.font = '600 10.5px ' + FONT_STACK;
ctx.fillStyle = '#d8cfae';
this.wrapText(ctx, entry.text, px + 16, ey + 16, pw - 32, 13);
} else {
ctx.textAlign = 'center';
ctx.font = '800 24px ' + FONT_STACK;
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.fillText('???', px + pw / 2, ey + 22);
ctx.font = '600 10px ' + FONT_STACK;
ctx.fillStyle = 'rgba(220,210,180,0.55)';
ctx.fillText(entry.hint, px + pw / 2, ey + 44);
}
});
});
// Back button
this.uiButtons = [
{
x: VW / 2 - 110, y: 490, w: 220, h: 36, label: 'Back to menu · C',
color: '#8fa8ba',
action: () => {
this.menuScreen = 'main';
this.audio.play('ui');
},
},
];
for (const b of this.uiButtons) this.drawUIButton(ctx, b);
}

/** Word-wrap helper; draws the text line by line, returns the count drawn. */
wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number): number {
const words = text.split(' ');
let line = '';
let lines = 0;
for (const w of words) {
const test = line ? line + ' ' + w : w;
if (ctx.measureText(test).width > maxWidth && line) {
ctx.fillText(line, x, y + lines * lineHeight);
lines += 1;
line = w;
} else {
line = test;
}
}
ctx.fillText(line, x, y + lines * lineHeight);
return lines + 1;
}

drawDebug(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = 'rgba(10,20,15,0.85)';
this.roundRect(ctx, VW - 250, VH - 118, 240, 108, 8);
Expand Down
2 changes: 2 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type GameKey =
| 'codex'
| 'skinPrev'
| 'skinNext'
| 'restart'
Expand Down Expand Up @@ -59,6 +60,7 @@ export class Input {
if (e.code === 'KeyM') this.onGameKey?.('mute');
if (e.code === 'KeyV') this.onGameKey?.('reducedMotion');
if (e.code === 'KeyG') this.onGameKey?.('ghost');
if (e.code === 'KeyC') this.onGameKey?.('codex');
if (e.code === 'BracketLeft') this.onGameKey?.('skinPrev');
if (e.code === 'BracketRight') this.onGameKey?.('skinNext');
if (e.code === 'F2') {
Expand Down
22 changes: 22 additions & 0 deletions src/level-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface LevelDef {
springs?: Point[];
/** Hidden fossils: persistent meta-collectibles (id = "<levelIdx>:<i>"). */
fossils?: Point[];
/** Field-note pages: lore collectibles read in the menu codex (id = "<levelIdx>:<i>"). */
notes?: Point[];
/** Pressure plates: hold to keep the referenced door (index into `doors`) open. */
plates?: { x: number; y: number; door: number }[];
/** Sliding gates: {x, y, w, h}; y is the top, bottom meets the ground. */
Expand Down Expand Up @@ -210,6 +212,11 @@ const LEVEL_1: LevelDef = {
{ x: 3230, y: 363 }, // stepping stone over lava pool A
{ x: 4990, y: 323 }, // stone ledge right above the spike pit
],
notes: [
{ x: 2400, y: 436 }, // by the first flag
{ x: 4630, y: 436 }, // by the last flag
{ x: 3264, y: 363 }, // beside the stepping stone over lava pool A
],
goal: { x: 7150, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down Expand Up @@ -337,6 +344,11 @@ const LEVEL_2: LevelDef = {
{ x: 5685, y: 313 }, // stone above the twin falling-rock gauntlet
{ x: 7010, y: 275 }, // high stone of the home-stretch jump chain
],
notes: [
{ x: 2270, y: 436 }, // by the first flag
{ x: 5719, y: 313 }, // beside the stone above the twin falling-rock gauntlet
{ x: 6380, y: 436 }, // by the last flag
],
goal: { x: 7350, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down Expand Up @@ -468,6 +480,11 @@ const LEVEL_3: LevelDef = {
{ x: 5925, y: 263 }, // bonus spring ledge, high above the rockfall ridge
{ x: 7700, y: 425 }, // behind the final gate, patrolled by a beetle
],
notes: [
{ x: 2850, y: 436 }, // by the first flag
{ x: 5891, y: 263 }, // beside the bonus spring ledge, high above the rockfall ridge
{ x: 7666, y: 425 }, // beside the fossil behind the final gate
],
goal: { x: 7850, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down Expand Up @@ -535,6 +552,11 @@ const LEVEL_4: LevelDef = {
{ x: 3320, y: 428 }, // arena nook between boss patrol and right wall
{ x: 3720, y: 428 }, // behind the gate, near the nest
],
notes: [
{ x: 2110, y: 436 }, // by the flag before the arena
{ x: 3286, y: 428 }, // arena nook beside the right wall
{ x: 3686, y: 428 }, // behind the gate, near the nest
],
boss: { x: 2760, y: 356, minX: 2320, maxX: 3160 },
orbs: [
{ x: 2435, y: 286 },
Expand Down
5 changes: 5 additions & 0 deletions src/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Door } from './door';
import { Projectile } from './projectile';
import type { ProjectileKind } from './projectile';
import { Fossil } from './fossil';
import { FieldNote } from './lore';
import { MagmaKing } from './boss';
import { PowerUp } from './powerup';
import type { PowerUpType } from './powerup';
Expand All @@ -39,6 +40,8 @@ export class Level {
doors: Door[];
projectiles: Projectile[];
fossils: Fossil[];
/** Field-note pages (lore codex). */
notes: FieldNote[];
/** The Magma King (Molten Nest only). */
boss: MagmaKing | null;
/** Power-up capsules dropped by stomped enemies. */
Expand All @@ -50,6 +53,7 @@ export class Level {
this.enemySpeed = enemySpeed;
this.game = game;
this.fossils = (d.fossils ?? []).map((f, i) => new Fossil(f.x, f.y, levelIdx + ':' + i));
this.notes = (d.notes ?? []).map((n, i) => new FieldNote(n.x, n.y, levelIdx + ':' + i));
this.springs = (d.springs ?? []).map((s) => new SpringPad(s.x, s.y));
this.plates = (d.plates ?? []).map((p) => new PressurePlate(p.x, p.y, game));
this.doors = (d.doors ?? []).map((dr) => new Door(dr.x, dr.y, dr.w, dr.h, game));
Expand Down Expand Up @@ -150,6 +154,7 @@ export class Level {
for (const c of this.crystals) c.collected = false;
for (const h of this.hearts) h.collected = false;
for (const f of this.fossils) f.collected = false;
for (const n of this.notes) n.collected = false;
for (const e of this.enemies) e.reset();
this.boss?.reset();
// Restore each hazard's own interval (the original hard-coded 2.2,
Expand Down
Loading
Loading