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
12 changes: 12 additions & 0 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,11 @@ export class AudioManager {
this.tone({ freq: 784, dur: 0.14, type: 'triangle', vol: 0.22, delay: 0.18 });
this.tone({ freq: 1046, dur: 0.26, type: 'sine', vol: 0.16, delay: 0.28 });
break;
case 'tide':
// Low whoosh of rising water: filtered noise bed + a slow swell tone.
this.noiseBurst({ dur: 0.5, vol: 0.12, freq: 420 });
this.tone({ freq: 120, to: 220, dur: 0.5, type: 'sine', vol: 0.1 });
break;
}
}

Expand Down Expand Up @@ -487,4 +492,11 @@ const MUSIC: Record<LevelTheme, { bpm: number; lead: number[]; bass: number[]; d
bass: [48, 0, 60, 0, 48, 0, 60, 0, 45, 0, 57, 0, 45, 0, 57, 0, 43, 0, 55, 0, 43, 0, 55, 0, 41, 0, 53, 0, 41, 0, 53, 0],
drums: [1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2, 1, 0, 0, 0, 2, 0, 0, 2],
},
dusk: {
// Slow minor-key adrift feel — sparse hats, no kicks, room for the tide.
bpm: 76,
lead: [69, 0, 72, 0, 76, 0, 72, 0, 71, 0, 67, 0, 71, 0, 69, 0, 64, 0, 67, 0, 71, 0, 74, 0, 71, 0, 67, 0, 69, 0, 0, 0],
bass: [45, 0, 0, 0, 52, 0, 0, 0, 43, 0, 0, 0, 50, 0, 0, 0, 40, 0, 0, 0, 47, 0, 0, 0, 45, 0, 0, 0, 52, 0, 0, 0],
drums: [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0],
},
};
32 changes: 25 additions & 7 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,15 @@ export class Background {
skyTop: '#7fb5e6', skyMid: '#bfe0f5', skyBot: '#eef7fc',
mtn: '#9db8d6', hill: '#cfe3f0',
},
dusk: {
skyTop: '#241d4a', skyMid: '#5d4478', skyBot: '#e08a5a',
mtn: '#332a54', hill: '#443562',
},
} as const;

draw(ctx: CanvasRenderingContext2D, camX: number, t: number): void {
const P = Background.PALETTES[this.theme];
const night = this.theme === 'volcanic';
const night = this.theme === 'volcanic' || this.theme === 'dusk';
const frost = this.theme === 'frost';

// Sky
Expand Down Expand Up @@ -146,8 +150,14 @@ export class Background {
ctx.lineTo(px + m.w, 420);
ctx.closePath();
ctx.fill();
// Snow cap (day) / faint ember rim (night) / bright cap (frost)
ctx.fillStyle = night ? 'rgba(255,140,60,0.28)' : frost ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.75)';
// Snow cap (day) / ember rim (volcanic) / warm rim (dusk) / bright cap (frost)
ctx.fillStyle = this.theme === 'volcanic'
? 'rgba(255,140,60,0.28)'
: this.theme === 'dusk'
? 'rgba(255,170,110,0.3)'
: frost
? 'rgba(255,255,255,0.95)'
: 'rgba(255,255,255,0.75)';
ctx.beginPath();
ctx.moveTo(px + m.w * m.peak, 420 - m.h);
ctx.lineTo(px + m.w * m.peak - m.w * 0.09, 420 - m.h + 26);
Expand All @@ -160,12 +170,20 @@ export class Background {
for (const c of this.clouds) {
const cx = ((c.x + t * c.drift - camX * 0.2) % 5200 + 5200) % 5200 - 200;
if (cx < -160 || cx > VW + 160) continue;
if (night) {
if (night && this.theme === 'volcanic') {
const a = 0.4 + 0.3 * Math.sin(t * 2 + c.x);
ctx.fillStyle = `rgba(255,128,54,${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(cx, c.y + Math.sin(t * 0.8 + c.x) * 6, 2.2 * c.s, 0, TAU);
ctx.fill();
} else if (this.theme === 'dusk') {
// Twilight clouds: soft purple slivers catching the last light
const a = 0.25 + 0.15 * Math.sin(t * 0.6 + c.x);
ctx.fillStyle = `rgba(226,150,120,${a.toFixed(2)})`;
ctx.beginPath();
ctx.ellipse(cx, c.y + 40 * c.s, 52 * c.s, 7 * c.s, 0, 0, TAU);
ctx.ellipse(cx + 34 * c.s, c.y + 36 * c.s, 30 * c.s, 5 * c.s, 0, 0, TAU);
ctx.fill();
} else {
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath();
Expand Down Expand Up @@ -270,7 +288,7 @@ export class Background {
private drawVolcano(ctx: CanvasRenderingContext2D, off: number, worldX: number, t: number, scale: number): void {
const x = worldX - off;
if (x < -400 || x > VW + 400) return;
const night = this.theme === 'volcanic';
const night = this.theme === 'volcanic' || this.theme === 'dusk';
ctx.fillStyle = night ? '#241830' : '#b0826a';
ctx.beginPath();
ctx.moveTo(x - 170 * scale, 520);
Expand All @@ -279,8 +297,8 @@ export class Background {
ctx.lineTo(x + 170 * scale, 520);
ctx.closePath();
ctx.fill();
// lava streak (brighter when the volcano theme is active)
ctx.strokeStyle = night ? '#ff8a3c' : '#e0703a';
// lava streak (bright when active, dimmed as the volcano cools at dusk)
ctx.strokeStyle = this.theme === 'volcanic' ? '#ff8a3c' : night ? '#c25a2e' : '#e0703a';
ctx.lineWidth = (night ? 6 : 5) * scale;
ctx.beginPath();
ctx.moveTo(x - 12 * scale, 255 - 40 * scale);
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const CFG = {
invulnTime: 1.6,
maxHearts: 3,
lavaBounce: 580,
/** Upward splash when the rising tide claims Rex. */
tideBounce: 300,
respawnInvuln: 2.2,
},
camera: { lerp: 5.5, lookAhead: 46, maxShake: 14 },
Expand Down
71 changes: 66 additions & 5 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,24 @@ export class Game implements GameCtx {
vrot: (Math.random() - 0.5) * 12,
}));
}
// Duskfen flourish: the fen falls silent as the nest comes home
if (this.levelIdx === 4 && !this.daily) {
this.addStatus('The fen falls silent. The nest is home.', '#c9a0ff');
this.audio.play('tide');
for (let i = 0; i < (this.reducedMotion ? 12 : 40); i++) {
this.particles.push(new Particle({
x: this.level!.goal.x - 140 + Math.random() * 280,
y: 40 + Math.random() * 80,
vx: (Math.random() - 0.5) * 30,
vy: 20 + Math.random() * 40,
life: 2 + Math.random(),
size: 4,
color: ['#c9a0ff', '#8fd0ff', '#ffd257'][i % 3],
grav: 40,
type: 'dot',
}));
}
}
// Final score
const timeBonus = Math.max(0, CFG.score.timeBonusBase - Math.floor(this.elapsed) * CFG.score.timeBonusPerSec);
const heartBonus = this.player!.hearts * CFG.score.heartBonus;
Expand Down Expand Up @@ -844,6 +862,22 @@ export class Game implements GameCtx {
for (const d of this.level?.doors ?? []) d.latched = true;
}

/**
* Rising tide (Duskfen): the waterline climbs while the run is live
* (playing or dying — the clock keeps pressure mid-knockback) and
* pings once when it first reaches the player.
*/
private updateTide(dt: number): void {
const lvl = this.level;
if (!lvl?.tide) return;
lvl.waterY = Math.max(lvl.tide.toY, lvl.waterY - lvl.tide.rate * dt);
if (!lvl.tideWarned && this.player && lvl.waterY < this.player.rect.y + this.player.rect.h) {
lvl.tideWarned = true;
this.addStatus('The tide is rising!', '#8fd0ff');
this.audio.play('tide');
}
}

update(dt: number): void {
this.input.pollGamepad();
this.audio.update(dt);
Expand All @@ -854,6 +888,7 @@ export class Game implements GameCtx {
this.ghost?.update(this.elapsed);
this.level!.update(dt, this.time, this.player!);
this.player!.update(dt, this.time, this.input, this.level!);
this.updateTide(dt);
this.weather.update(dt, this.player!);
this.camera.update(dt, this.player!, this.level!.width, this);
this.updateAdaptive();
Expand Down Expand Up @@ -888,6 +923,7 @@ export class Game implements GameCtx {
this.dyingT += dt;
this.level!.update(dt, this.time, this.player!);
this.player!.update(dt, this.time, this.input, this.level!);
this.updateTide(dt);
this.weather.update(dt, this.player!);
this.camera.update(dt, this.player!, this.level!.width, this);
if (this.dyingT > 1.15) {
Expand Down Expand Up @@ -1031,6 +1067,27 @@ export class Game implements GameCtx {

ctx.restore();

// Rising tide (screen space — the water spans the whole viewport)
if (this.level?.tide) {
const top = this.level.waterY;
if (Number.isFinite(top) && top < VH) {
const wg = ctx.createLinearGradient(0, top, 0, VH);
wg.addColorStop(0, 'rgba(72,138,186,0.72)');
wg.addColorStop(1, 'rgba(14,40,78,0.94)');
ctx.fillStyle = wg;
ctx.fillRect(0, top, VW, VH - top);
ctx.beginPath();
for (let x = 0; x <= VW; x += 12) {
const y = top + Math.sin(x * 0.02 + this.time * 2.4) * 2.5;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = 'rgba(190,225,255,0.85)';
ctx.lineWidth = 2;
ctx.stroke();
}
}

// Vignette-ish bottom fade for depth
const vg = ctx.createLinearGradient(0, VH - 60, 0, VH);
vg.addColorStop(0, 'rgba(40,30,20,0)');
Expand Down Expand Up @@ -1751,11 +1808,12 @@ export class Game implements GameCtx {
// Draw a slice of the level floor for grounding
const volcanic = this.bg.theme === 'volcanic';
const frost = this.bg.theme === 'frost';
ctx.fillStyle = volcanic ? '#42304a' : frost ? '#8fa8c4' : '#8a5f3c';
const dusk = this.bg.theme === 'dusk';
ctx.fillStyle = volcanic ? '#42304a' : frost ? '#8fa8c4' : dusk ? '#3a3050' : '#8a5f3c';
ctx.fillRect(0, 460, VW, 80);
ctx.fillStyle = volcanic ? '#5c4258' : frost ? '#e8f4fc' : '#5da854';
ctx.fillStyle = volcanic ? '#5c4258' : frost ? '#e8f4fc' : dusk ? '#54436e' : '#5da854';
ctx.fillRect(0, 460, VW, 12);
ctx.fillStyle = volcanic ? '#8a5a78' : frost ? '#ffffff' : '#6fbe62';
ctx.fillStyle = volcanic ? '#8a5a78' : frost ? '#ffffff' : dusk ? '#8a6f9e' : '#6fbe62';
ctx.fillRect(0, 460, VW, 5);
// Decor
drawDecor(ctx, { type: 'tree', x: 130, s: 1.1 }, this.time, 460);
Expand Down Expand Up @@ -1789,7 +1847,7 @@ export class Game implements GameCtx {
ctx.font = '800 64px ' + FONT_STACK;
ctx.lineJoin = 'round';
ctx.lineWidth = 6;
ctx.strokeStyle = volcanic ? 'rgba(60,25,20,0.85)' : 'rgba(30,50,30,0.85)';
ctx.strokeStyle = volcanic ? 'rgba(60,25,20,0.85)' : dusk ? 'rgba(30,18,44,0.85)' : 'rgba(30,50,30,0.85)';
ctx.strokeText('TINY REX', VW / 2, 118 + bounce);
const tg = ctx.createLinearGradient(0, 56, 0, 124);
if (volcanic) {
Expand All @@ -1798,6 +1856,9 @@ export class Game implements GameCtx {
} else if (frost) {
tg.addColorStop(0, '#eaf7ff');
tg.addColorStop(1, '#7fb5e6');
} else if (dusk) {
tg.addColorStop(0, '#f0c880');
tg.addColorStop(1, '#8a5fa8');
} else {
tg.addColorStop(0, '#c8f0a0');
tg.addColorStop(1, '#5da854');
Expand Down Expand Up @@ -1882,7 +1943,7 @@ export class Game implements GameCtx {
const cardGap = 14;
const cardW = Math.floor((680 - cardGap * (cardCount - 1)) / cardCount);
const cardX0 = VW / 2 - (cardW * cardCount + cardGap * (cardCount - 1)) / 2;
const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff', '#ff7a5c'];
const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff', '#ff7a5c', '#a9c2ff'];
for (let i = 0; i < cardCount; i++) {
const isDaily = i === LEVELS.length;
const cx0 = cardX0 + i * (cardW + cardGap);
Expand Down
Loading
Loading