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
7 changes: 7 additions & 0 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,13 @@ export class AudioManager {
this.tone({ freq: 340, to: 130, dur: 0.16, type: 'sawtooth', vol: 0.14 });
this.noiseBurst({ dur: 0.08, vol: 0.08, freq: 700 });
break;
case 'fossil': {
// Deep unearth thud followed by a warm two-note chime.
this.noiseBurst({ dur: 0.1, vol: 0.14, freq: 420 });
this.tone({ freq: 392, dur: 0.14, type: 'triangle', vol: 0.2, delay: 0.04 });
this.tone({ freq: 587, dur: 0.2, type: 'sine', vol: 0.18, delay: 0.14 });
break;
}
case 'cheat':
[784, 988, 1175, 1568].forEach((f, i) =>
this.tone({ freq: f, dur: 0.09, type: 'sine', vol: 0.16, delay: i * 0.05 }),
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const CFG = {
heartBonus: 400,
/** Points a healing heart is worth when collected at full health. */
heartFull: 200,
/** Points a hidden fossil is worth (re-collectable each run). */
fossil: 150,
timeBonusBase: 2400,
timeBonusPerSec: 10,
},
Expand Down
5 changes: 5 additions & 0 deletions src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ export interface GameCtx {
* the player is already at full health.
*/
collectHeart(x: number, y: number): void;
/**
* A hidden fossil was unearthed: score + sparkle, and the first discovery
* is persisted to the fossil codex (id = "<levelIdx>:<i>").
*/
collectFossil(x: number, y: number, id: string): void;
}
82 changes: 82 additions & 0 deletions src/fossil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { TAU } from './config';

/**
* A hidden fossil: a persistent meta-collectible. Found once (stored across
* runs by id "<levelIdx>:<i>"), but re-collectable for score on later runs.
*/
export class Fossil {
x: number;
y: number;
w = 28;
h = 20;
id: string;
collected = false;
phase = Math.random() * TAU;

constructor(x: number, y: number, id: string) {
this.x = x;
this.y = y;
this.id = id;
}

get rect(): { x: number; y: number; w: number; h: number } {
return { x: this.x - 15, y: this.y - 13, w: 30, h: 26 };
}

draw(ctx: CanvasRenderingContext2D, t: number): void {
const bob = Math.sin(t * 1.8 + this.phase) * 2.5; // heavy — bobs less than crystals
const cx = this.x;
const cy = this.y + bob;
const pulse = 0.5 + 0.5 * Math.sin(t * 2.2 + this.phase);
ctx.save();
ctx.translate(cx, cy);
// warm unearthed glow
ctx.globalAlpha = 0.22 + 0.16 * pulse;
ctx.fillStyle = '#e8dcc0';
ctx.beginPath();
ctx.arc(0, 0, 17 + 2 * pulse, 0, TAU);
ctx.fill();
ctx.globalAlpha = 1;
// bone shaft
const grad = ctx.createLinearGradient(0, -5, 0, 5);
grad.addColorStop(0, '#f4ecd9');
grad.addColorStop(1, '#cbb98f');
ctx.fillStyle = grad;
ctx.fillRect(-10, -3.5, 20, 7);
// knob ends (two circles per side)
for (const sx of [-1, 1]) {
for (const sy of [-3.2, 3.2]) {
ctx.beginPath();
ctx.arc(sx * 11, sy, 4, 0, TAU);
ctx.fill();
}
}
// weathering crack
ctx.strokeStyle = 'rgba(110,90,55,0.55)';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(-4, -3);
ctx.lineTo(-1, 0);
ctx.lineTo(-4, 3);
ctx.stroke();
// outline
ctx.strokeStyle = 'rgba(110,90,55,0.4)';
ctx.lineWidth = 1;
ctx.strokeRect(-10, -3.5, 20, 7);
ctx.restore();
// sparkle
if (Math.sin(t * 1.9 + this.phase * 3) > 0.88) {
ctx.save();
ctx.translate(cx + 10, cy - 11);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(-3.5, 0);
ctx.lineTo(3.5, 0);
ctx.moveTo(0, -3.5);
ctx.lineTo(0, 3.5);
ctx.stroke();
ctx.restore();
}
}
}
81 changes: 73 additions & 8 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 } from './store';
import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil } from './store';
import type { GameStats } from './store';
import { AudioManager } from './audio';
import { Input } from './input';
Expand Down Expand Up @@ -122,6 +122,8 @@ export class Game implements GameCtx {
rainbow = false;
/** Ghost race: replay the stored best run alongside the player. */
ghostOn: boolean = getGhostEnabled();
/** Fossil ids discovered so far (persistent meta-progress). */
fossilsFound: string[] = getFoundFossils();
private ghost: GhostPlayer | null = null;
private ghostRec: GhostRecorder | null = null;
/** Cheat queue: apply max hearts once a player exists. */
Expand Down Expand Up @@ -260,10 +262,16 @@ export class Game implements GameCtx {
/* ---------- lifecycle ---------- */
buildLevel(): void {
const info = this.currentInfo();
this.level = new Level(info.def, this, DIFFICULTIES[this.difficulty].enemySpeed);
// Daily levels have no fossils; the sentinel keeps fossil ids stable.
this.level = new Level(info.def, this, DIFFICULTIES[this.difficulty].enemySpeed, this.daily ? -1 : this.levelIdx);
this.bg.theme = info.theme;
}

/** Total hidden fossils across the hand-built levels. */
totalFossils(): number {
return LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0);
}

startGame(): void {
this.buildLevel();
this.player = new Player(this.level!.start.x, this.level!.start.y, this);
Expand Down Expand Up @@ -524,6 +532,25 @@ export class Game implements GameCtx {
}
}

/** Fossil pickup (GameCtx): persistent discovery + re-collectable score. */
collectFossil(x: number, y: number, id: string): void {
const first = !this.fossilsFound.includes(id);
if (first) {
findFossil(id);
this.fossilsFound = getFoundFossils();
}
this.addScore(CFG.score.fossil, x, y - 14);
this.audio.play('fossil');
if (first) {
this.addStatus('Fossil found! ' + this.fossilsFound.length + '/' + this.totalFossils(), '#e8dcc0');
this.addShake(2);
this.burst(x, y, 22, ['#f4ecd9', '#e8dcc0', '#cbb98f', '#fff'], 'dot', 170);
this.texts.push(new FloatingText(x, y - 34, 'NEW FOSSIL!', '#f4ecd9'));
} else {
this.burst(x, y, 10, ['#f4ecd9', '#e8dcc0'], '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 @@ -753,6 +780,9 @@ export class Game implements GameCtx {
for (const h of this.level!.hearts) {
if (!h.collected) h.draw(ctx, this.time);
}
for (const f of this.level!.fossils) {
if (!f.collected) f.draw(ctx, this.time);
}
for (const e of this.level!.enemies) {
if (e.dead) continue;
if (e.x + e.w < camX - 60 || e.x > camX + VW + 60) continue;
Expand Down Expand Up @@ -1048,17 +1078,34 @@ export class Game implements GameCtx {
ctx.font = '800 17px ' + FONT_STACK;
ctx.textAlign = 'left';
ctx.fillText('× ' + (this.crystalsGot || 0) + '/' + (this.level ? this.level.totalCrystals : 0), cx + 14, 38);
// Score & time (right)
// Score, time & fossil count (right)
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(20,30,45,0.55)';
this.roundRect(ctx, VW - 190, 10, 180, 62, 12);
this.roundRect(ctx, VW - 190, 10, 180, 84, 12);
ctx.fill();
ctx.fillStyle = '#ffe28a';
ctx.font = '800 19px ' + FONT_STACK;
ctx.fillText('Score ' + this.score, VW - 24, 34);
ctx.fillStyle = '#cfe8ff';
ctx.font = '700 15px ' + FONT_STACK;
ctx.fillText('Time ' + fmtTime(this.elapsed), VW - 24, 58);
ctx.fillText('Time ' + fmtTime(this.elapsed), VW - 24, 56);
// Fossil meta-progress with a tiny bone glyph
ctx.font = '700 13px ' + FONT_STACK;
const fossilTxt = 'Fossils ' + this.fossilsFound.length + '/' + this.totalFossils();
const fossilW = ctx.measureText(fossilTxt).width;
ctx.fillText(fossilTxt, VW - 24, 80);
ctx.save();
ctx.translate(VW - 24 - fossilW - 16, 75);
ctx.fillStyle = '#e8dcc0';
ctx.fillRect(-6, -1.8, 12, 3.6);
for (const kx of [-7, 7]) {
for (const ky of [-2.2, 2.2]) {
ctx.beginPath();
ctx.arc(kx, ky, 2.2, 0, TAU);
ctx.fill();
}
}
ctx.restore();
// Progress toward the nest (top centre, between the panels)
if (this.player && this.level) this.drawProgress(ctx);
// Combo chip while a crystal chain is alive
Expand Down Expand Up @@ -1495,7 +1542,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 + ' · SINCE ' + since,
'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · SINCE ' + since,
VW / 2,
205,
);
Expand Down Expand Up @@ -1532,6 +1579,7 @@ export class Game implements GameCtx {
'Stomp beetles, trikes & pteros',
'Watch for lava, spikes & rocks',
'Touch flags to save progress',
'Unearth the hidden fossils',
];
ctx.font = '600 13px ' + FONT_STACK;
quest.forEach((r, i) => {
Expand Down Expand Up @@ -1582,13 +1630,30 @@ export class Game implements GameCtx {
ctx.stroke();
}
}
// Fossil dots (hand-built levels only: one per hidden fossil)
if (!isDaily) {
const fCount = LEVELS[i].def.fossils?.length ?? 0;
for (let f = 0; f < fCount; f++) {
const found = this.fossilsFound.includes(i + ':' + f);
const fx = cx0 + cw / 2 + (f - (fCount - 1) / 2) * 18;
ctx.fillStyle = found ? '#f4ecd9' : 'rgba(255,255,255,0.16)';
ctx.fillRect(fx - 5, cardY + 82, 10, 2.6);
for (const kx of [-5, 5]) {
for (const ky of [-2.4, 2.4]) {
ctx.beginPath();
ctx.arc(fx + kx, cardY + 83.3 + ky, 2.4, 0, TAU);
ctx.fill();
}
}
}
}
// Records
const bb = isDaily ? getDailyBest() : getBest(i);
ctx.font = '600 12px ' + FONT_STACK;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(220,232,245,0.85)';
ctx.fillText('Score ' + (bb.score || '—'), cx0 + cw / 2, cardY + 98);
ctx.fillText('Time ' + (bb.time === null ? '—' : fmtTime(bb.time)), cx0 + cw / 2, cardY + 118);
ctx.fillText('Score ' + (bb.score || '—'), cx0 + cw / 2, cardY + 104);
ctx.fillText('Time ' + (bb.time === null ? '—' : fmtTime(bb.time)), cx0 + cw / 2, cardY + 122);
ctx.font = '600 11px ' + FONT_STACK;
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText(isDaily ? dailyLabel(dailySeed()) : 'TAP · ←/→', cx0 + cw / 2, cardY + 138);
Expand Down
17 changes: 17 additions & 0 deletions src/level-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface LevelDef {
decor: DecorDef[];
/** Spring pads: {x, groundTopY}. Launch the player upward. */
springs?: Point[];
/** Hidden fossils: persistent meta-collectibles (id = "<levelIdx>:<i>"). */
fossils?: 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 @@ -199,6 +201,11 @@ const LEVEL_1: LevelDef = {
{ x: 2660, y: 330 }, // stone ledge before the spikes
{ x: 5080, y: 428 }, // just past the spike gauntlet
],
fossils: [
{ x: 3255, y: 163 }, // high bonus route — top of the jump chain
{ x: 3230, y: 363 }, // stepping stone over lava pool A
{ x: 4990, y: 323 }, // stone ledge right above the spike pit
],
goal: { x: 7150, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down Expand Up @@ -321,6 +328,11 @@ const LEVEL_2: LevelDef = {
{ x: 4300, y: 428 }, // safe ground after the lava moat
{ x: 6340, y: 332 }, // stone ledge above the rock gauntlet
],
fossils: [
{ x: 2830, y: 393 }, // stepping stone mid-moat, lava on both sides
{ x: 5685, y: 313 }, // stone above the twin falling-rock gauntlet
{ x: 7010, y: 275 }, // high stone of the home-stretch jump chain
],
goal: { x: 7350, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down Expand Up @@ -447,6 +459,11 @@ const LEVEL_3: LevelDef = {
{ x: 3720, y: 428 }, // just past the first gate
{ x: 7720, y: 428 }, // past the final gate, near the goal
],
fossils: [
{ x: 3860, y: 425 }, // just behind the first gate
{ x: 5925, y: 263 }, // bonus spring ledge, high above the rockfall ridge
{ x: 7700, y: 425 }, // behind the final gate, patrolled by a beetle
],
goal: { x: 7850, y: 460 },
decor: [
{ type: 'sign', x: 120 },
Expand Down
6 changes: 5 additions & 1 deletion src/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SpringPad } from './spring';
import { PressurePlate } from './plate';
import { Door } from './door';
import { Projectile } from './projectile';
import { Fossil } from './fossil';

export class Level {
width: number;
Expand All @@ -33,12 +34,14 @@ export class Level {
plates: PressurePlate[];
doors: Door[];
projectiles: Projectile[];
fossils: Fossil[];
readonly game: GameCtx;

constructor(d: LevelDef, game: GameCtx, enemySpeed = 1) {
constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) {
this.width = d.width;
this.enemySpeed = enemySpeed;
this.game = game;
this.fossils = (d.fossils ?? []).map((f, i) => new Fossil(f.x, f.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 @@ -114,6 +117,7 @@ export class Level {
this.projectiles = [];
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 e of this.enemies) e.reset();
// Restore each hazard's own interval (the original hard-coded 2.2,
// clobbering hazards configured with a different one).
Expand Down
9 changes: 9 additions & 0 deletions src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ export class Player {
}
}

// --- Fossils (persistent discoveries, re-collectable for score) ---
for (const f of level.fossils) {
if (f.collected) continue;
if (overlap(this.rect, f.rect)) {
f.collected = true;
this.game.collectFossil(f.x, f.y, f.id);
}
}

// --- Enemies: stomp from above, hurt from the side ---
for (const e of level.enemies) {
if (e.dead) continue;
Expand Down
17 changes: 17 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ export function saveGhostTrack(idx: number, track: GhostTrack): void {
Store.set(key, track);
}

/**
* Found fossil ids ("<levelIdx>:<i>"), persistent across runs. A fossil can
* be re-collected for score on later runs, but only the first discovery
* counts toward the codex.
*/
export function getFoundFossils(): string[] {
const v = Store.get<string[] | null>('tinyrex_fossils', null);
return Array.isArray(v) ? v : [];
}

/** Record a fossil discovery; no-op when it is already in the codex. */
export function findFossil(id: string): void {
const found = getFoundFossils();
if (found.includes(id)) return;
Store.set('tinyrex_fossils', [...found, id]);
}

/** Safe localStorage wrapper (settings + best records). */
export const Store = {
get<T>(key: string, fallback: T): T {
Expand Down
Loading
Loading