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
109 changes: 104 additions & 5 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 } from './store';
import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getSkinId, setSkinId } from './store';
import type { GameStats } from './store';
import { AudioManager } from './audio';
import { Input } from './input';
Expand All @@ -17,7 +17,7 @@ import type { LevelInfo } from './level-data';
import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily';
import { GhostRecorder, GhostPlayer } from './ghost';
import { drawDecor } from './decor';
import { Sprite } from './sprite';
import { Sprite, SKINS, skinUnlocked } from './sprite';
import type { GameCtx } from './ctx';
import type { Checkpoint } from './checkpoint';
import type { Platform } from './platform';
Expand Down Expand Up @@ -120,6 +120,8 @@ export class Game implements GameCtx {
godMode = false;
/** Cheat: rainbow Rex skin (persists across runs). */
rainbow = false;
/** Cosmetic skin id (see SKINS); Mint unlocks at 3 fossils. */
skin: string = getSkinId();
/** Ghost race: replay the stored best run alongside the player. */
ghostOn: boolean = getGhostEnabled();
/** Fossil ids discovered so far (persistent meta-progress). */
Expand Down Expand Up @@ -251,6 +253,33 @@ export class Game implements GameCtx {
this.audio.play('ui');
}

/** Choose a cosmetic skin (Mint needs 3 fossils). */
selectSkin(id: string): void {
if (!skinUnlocked(id, this.fossilsFound)) {
this.audio.play('ui');
return;
}
if (id === this.skin) return;
this.skin = id;
setSkinId(id);
if (this.player) this.player.skin = id;
if (this.ghost) this.ghost.skin = id;
this.audio.play('ui');
}

/** Cycle skins with [ / ] on the menu; locked skins are skipped. */
cycleSkin(dir: 1 | -1): void {
let idx = SKINS.findIndex((s) => s.id === this.skin);
if (idx < 0) idx = 0;
for (let i = 1; i <= SKINS.length; i++) {
const cand = SKINS[(((idx + dir * i) % SKINS.length) + SKINS.length) % SKINS.length];
if (cand.id !== this.skin && skinUnlocked(cand.id, this.fossilsFound)) {
this.selectSkin(cand.id);
return;
}
}
}

/** Advance to the next level and start a fresh run. */
nextLevel(): void {
this.levelIdx = (this.levelIdx + 1) % LEVELS.length;
Expand Down Expand Up @@ -278,6 +307,7 @@ export class Game implements GameCtx {
this.player.maxHearts = this.maxHearts;
this.player.hearts = this.maxHearts;
this.player.rainbow = this.rainbow;
this.player.skin = this.skin;
if (this.maxHeartsCheat) {
this.maxHeartsCheat = false;
this.player.maxHearts = Game.MAX_HEARTS_CAP;
Expand Down Expand Up @@ -306,7 +336,10 @@ export class Game implements GameCtx {
this.ghost = null;
if (this.ghostOn) {
const track = getGhostTrack(this.daily ? -1 : this.levelIdx, this.daily ? dailySeed() : 0);
if (track) this.ghost = new GhostPlayer(track);
if (track) {
this.ghost = new GhostPlayer(track);
this.ghost.skin = this.skin;
}
}
this.camera.x = 0;
this.camera.shake = 0;
Expand Down Expand Up @@ -387,6 +420,10 @@ export class Game implements GameCtx {
this.toggleGhost();
return;
}
if ((k === 'skinPrev' || k === 'skinNext') && this.state === 'menu') {
this.cycleSkin(k === 'skinNext' ? 1 : -1);
return;
}
if (k === 'debug') {
this.debug = !this.debug;
return;
Expand Down Expand Up @@ -546,6 +583,10 @@ export class Game implements GameCtx {
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'));
if (this.fossilsFound.length === 3) {
this.addStatus('Mint Rex unlocked! Pick it on the menu', '#63e0a8');
this.audio.play('star');
}
} else {
this.burst(x, y, 10, ['#f4ecd9', '#e8dcc0'], 'dot', 130);
}
Expand Down Expand Up @@ -1498,6 +1539,7 @@ export class Game implements GameCtx {
dead: false,
rot: 0,
rainbow: this.rainbow,
skin: this.skin,
};
Sprite.drawRex(ctx, fakeP, this.time);
// Title block (gently floating, theme-aware)
Expand Down Expand Up @@ -1547,7 +1589,7 @@ export class Game implements GameCtx {
205,
);
// Controls panel (left)
const cpx = 34, cpy = 232, cpw = 240, cph = 232;
const cpx = 34, cpy = 232, cpw = 240, cph = 238;
this.drawInfoPanel(ctx, cpx, cpy, cpw, cph, 'CONTROLS');
const rows: Array<[string, string]> = [
['Move', 'A / D · ← →'],
Expand All @@ -1558,6 +1600,7 @@ export class Game implements GameCtx {
['Difficulty', '↑ / ↓'],
['Mute · Calm · Ghost', 'M · V · G'],
['Gamepad', 'A jump · B go'],
['Skin', '[ / ]'],
['Debug', 'F2'],
];
ctx.font = '600 13px ' + FONT_STACK;
Expand All @@ -1571,7 +1614,7 @@ export class Game implements GameCtx {
ctx.fillText(keys, cpx + cpw - 20, y);
});
// Quest panel (right), rows vertically centred against the controls list
const qx = VW - 274, qy = 232, qw = 240, qh = 232;
const qx = VW - 274, qy = 232, qw = 240, qh = 238;
this.drawInfoPanel(ctx, qx, qy, qw, qh, 'YOUR QUEST');
const quest = [
'Reach the glowing nest',
Expand Down Expand Up @@ -1685,6 +1728,60 @@ export class Game implements GameCtx {
ctx.font = '800 12px ' + FONT_STACK;
this.drawTracked(ctx, p.label, p.x + 45, 413, 2, false);
});
// Skin picker (right of the difficulty pills)
ctx.font = '700 11px ' + FONT_STACK;
ctx.fillStyle = '#cfe0f2';
this.drawTracked(ctx, 'REX SKIN', 686, 413, 2, false);
SKINS.forEach((s, i) => {
const sx = 746 + i * 48;
const unlocked = skinUnlocked(s.id, this.fossilsFound);
ctx.save();
if (!unlocked) ctx.globalAlpha = 0.35;
this.roundRect(ctx, sx, 394, 40, 30, 8);
ctx.fillStyle = s.body;
ctx.fill();
// mini Rex head: spikes + eye
ctx.fillStyle = s.dark;
for (let k = 0; k < 3; k++) {
ctx.beginPath();
ctx.moveTo(sx + 11 + k * 5, 402);
ctx.lineTo(sx + 13.5 + k * 5, 397 + k * 1.2);
ctx.lineTo(sx + 16 + k * 5, 402);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(sx + 26, 407, 3.6, 0, TAU);
ctx.fill();
ctx.fillStyle = s.line;
ctx.beginPath();
ctx.arc(sx + 27, 407, 1.6, 0, TAU);
ctx.fill();
ctx.restore();
if (!unlocked) {
// padlock over locked skins
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(sx + 20, 406, 3.6, Math.PI, 0);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
this.roundRect(ctx, sx + 15, 406, 10, 8, 2);
ctx.fill();
} else if (this.skin === s.id) {
ctx.strokeStyle = '#ffd257';
ctx.lineWidth = 2;
this.roundRect(ctx, sx + 1, 395, 38, 28, 7);
ctx.stroke();
}
});
if (!skinUnlocked('mint', this.fossilsFound)) {
ctx.font = '600 10px ' + FONT_STACK;
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'center';
ctx.fillText('3 fossils to unlock Mint', 842, 436);
}
// Bottom bar: Start + toggles (sitting on the ground strip for contrast)
this.uiButtons = [
// Tappable level cards (drawn above, hit-tested here)
Expand All @@ -1709,6 +1806,8 @@ export class Game implements GameCtx {
},
// Tappable difficulty pills (drawn above)
...pills.map((p) => ({ x: p.x, y: 394, w: 90, h: 30, label: p.label, card: true, action: () => this.selectDifficulty(p.d) })),
// Tappable skin swatches (drawn above)
...SKINS.map((s, i) => ({ x: 746 + i * 48, y: 394, w: 40, h: 30, label: s.name, card: true, action: () => this.selectSkin(s.id) })),
{
x: 38, y: 474, w: 150, h: 40, label: 'Ghost: ' + (this.ghostOn ? 'On' : 'Off') + ' · G',
color: '#8fa8ba',
Expand Down
4 changes: 4 additions & 0 deletions src/ghost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export class GhostPlayer {
return !this.finished;
}

/** Cosmetic skin to replay with (set by the Game to the player's skin). */
skin: string | null = null;

/** RexView for Sprite.drawRex (Player satisfies the same shape). */
get view(): RexView {
return {
Expand All @@ -113,6 +116,7 @@ export class GhostPlayer {
invulnT: 0,
dead: false,
rot: 0,
skin: this.skin ?? undefined,
};
}
}
4 changes: 4 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type GameKey =
| 'skinPrev'
| 'skinNext'
| 'restart'
| 'mute'
| 'reducedMotion'
Expand Down Expand Up @@ -57,6 +59,8 @@ 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 === 'BracketLeft') this.onGameKey?.('skinPrev');
if (e.code === 'BracketRight') this.onGameKey?.('skinNext');
if (e.code === 'F2') {
e.preventDefault();
this.onGameKey?.('debug');
Expand Down
2 changes: 2 additions & 0 deletions src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export class Player {
jumpCutPending = false;
/** Konami code: rainbow skin. */
rainbow = false;
/** Cosmetic skin id (see SKINS in sprite.ts); set by the Game. */
skin = 'classic';
private readonly game: GameCtx;

constructor(x: number, y: number, game: GameCtx) {
Expand Down
60 changes: 49 additions & 11 deletions src/sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ import { TAU } from './config';
import type { Enemy } from './enemy';
import type { PlayerState } from './player';

/** Rex skin palettes. Mint unlocks after finding 3 hidden fossils. */
export type RexSkinId = 'classic' | 'ember' | 'glacier' | 'mint';

export interface RexSkin {
id: RexSkinId;
name: string;
/** Main body / tail / head color. */
body: string;
/** Legs, arms, head spikes. */
dark: string;
/** Belly. */
belly: string;
/** Nostril, smile, eyes. */
line: string;
}

export const SKINS: RexSkin[] = [
{ id: 'classic', name: 'Classic', body: '#59b25f', dark: '#4c9d52', belly: '#f7ecd4', line: '#2f6e36' },
{ id: 'ember', name: 'Ember', body: '#ef8a4c', dark: '#d9672c', belly: '#ffe9cf', line: '#8a3d16' },
{ id: 'glacier', name: 'Glacier', body: '#7cc4e8', dark: '#55a3d4', belly: '#eaf7ff', line: '#2b6e94' },
{ id: 'mint', name: 'Mint', body: '#63e0a8', dark: '#3ecf96', belly: '#eafff5', line: '#1e7a58' },
];

/** Resolve an arbitrary stored id to a known skin (fallback: Classic). */
export function getSkin(id?: string): RexSkin {
return SKINS.find((s) => s.id === id) ?? SKINS[0];
}

/** Mint is the fossil reward; every other skin is free. */
export function skinUnlocked(id: string, fossilsFound: string[]): boolean {
return id !== 'mint' || fossilsFound.length >= 3;
}

/** The state fields Sprite.drawRex reads (Player satisfies this structurally). */
export interface RexView {
x: number;
Expand All @@ -18,8 +51,10 @@ export interface RexView {
dead: boolean;
/** Death-tumble rotation (radians); 0 outside the dead state. */
rot: number;
/** Konami code: cycle body colors through the rainbow. */
/** Konami code: cycle body colors through the rainbow (overrides skin). */
rainbow?: boolean;
/** Cosmetic skin id (see SKINS); defaults to Classic. */
skin?: string;
}

/** Procedural character & enemy art. */
Expand All @@ -43,10 +78,13 @@ export const Sprite = {
const legB = run ? Math.sin(phase + Math.PI) * 7 : 0;
const tailWag = run ? Math.sin(phase * 1.5) * 4 : Math.sin(t * 2.4) * 2;
const breathe = 1 + Math.sin(t * 3) * 0.02;
// Rainbow Rex (Konami): hue-cycle the body, keep the cream belly.
// Rainbow Rex (Konami) overrides any skin: hue-cycle the body.
const skin = getSkin(p.skin);
const hue = (t * 160) % 360;
const bodyC = p.rainbow ? `hsl(${hue.toFixed(0)}, 78%, 55%)` : '#59b25f';
const darkC = p.rainbow ? `hsl(${((hue + 160) % 360).toFixed(0)}, 60%, 44%)` : '#4c9d52';
const bodyC = p.rainbow ? `hsl(${hue.toFixed(0)}, 78%, 55%)` : skin.body;
const darkC = p.rainbow ? `hsl(${((hue + 160) % 360).toFixed(0)}, 60%, 44%)` : skin.dark;
const bellyC = p.rainbow ? '#f7ecd4' : skin.belly;
const lineC = p.rainbow ? '#2f6e36' : skin.line;

// Tail (behind body)
ctx.fillStyle = bodyC;
Expand All @@ -71,7 +109,7 @@ export const Sprite = {
ctx.ellipse(0, -20 + bob * 0.4, 15, 13 * breathe, 0, 0, TAU);
ctx.fill();
// Cream belly
ctx.fillStyle = '#f7ecd4';
ctx.fillStyle = bellyC;
ctx.beginPath();
ctx.ellipse(3, -17 + bob * 0.4, 9, 8 * breathe, 0, 0, TAU);
ctx.fill();
Expand Down Expand Up @@ -99,12 +137,12 @@ export const Sprite = {
ctx.ellipse(12, hy + 4, 8.5, 6.5, 0, 0, TAU);
ctx.fill();
// Nostril
ctx.fillStyle = '#2f6e36';
ctx.fillStyle = lineC;
ctx.beginPath();
ctx.arc(16, hy + 2, 1.2, 0, TAU);
ctx.fill();
// Smile
ctx.strokeStyle = '#2f6e36';
ctx.strokeStyle = lineC;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(11, hy + 5, 4.5, 0.15 * Math.PI, 0.75 * Math.PI);
Expand All @@ -116,7 +154,7 @@ export const Sprite = {
ctx.fill();
if (dead) {
// X eyes
ctx.strokeStyle = '#2f6e36';
ctx.strokeStyle = lineC;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(2.5, hy - 6);
Expand All @@ -125,13 +163,13 @@ export const Sprite = {
ctx.lineTo(2.5, hy - 1);
ctx.stroke();
} else if (p.state === 'hurt') {
ctx.fillStyle = '#2f6e36';
ctx.fillStyle = lineC;
ctx.beginPath();
ctx.arc(5.5, hy - 3, 2.2, 0, TAU);
ctx.fill();
} else if (jump) {
// wide eyes in the air
ctx.fillStyle = '#2f6e36';
ctx.fillStyle = lineC;
ctx.beginPath();
ctx.arc(6, hy - 3.5, 3.2, 0, TAU);
ctx.fill();
Expand All @@ -140,7 +178,7 @@ export const Sprite = {
ctx.arc(7, hy - 4.5, 1.1, 0, TAU);
ctx.fill();
} else {
ctx.fillStyle = '#2f6e36';
ctx.fillStyle = lineC;
const lookY = p.vy < -50 ? -1 : 0;
ctx.beginPath();
ctx.arc(6, hy - 3 + lookY, 2.8, 0, TAU);
Expand Down
Loading
Loading