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
34 changes: 34 additions & 0 deletions src/adaptive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Adaptive-soundtrack trigger logic, kept pure so it is unit-testable.
* The audio manager fades its "urgent" and "shimmer" layers based on
* what these flags say about the current run.
*/
export interface AdaptiveInput {
hearts: number;
playerX: number;
bossAlive: boolean;
hazards: { type: string; x: number; w: number }[];
crystals: { x: number; collected: boolean }[];
}

/** How far ahead of Rex (px) a hazard counts as "coming up". */
export const DANGER_AHEAD = 420;
/** Uncollected crystals within this radius (px) light the shimmer layer. */
export const SHIMMER_RADIUS = 280;
/** ...and only when at least this many of them are nearby. */
export const SHIMMER_MIN = 4;

export function adaptiveFlags(i: AdaptiveInput): { urgent: boolean; shimmer: boolean } {
const danger = i.hazards.some(
(h) =>
(h.type === 'spikes' || h.type === 'lava' || h.type === 'rocks') &&
h.x + h.w >= i.playerX - 40 &&
h.x <= i.playerX + DANGER_AHEAD,
);
const urgent = i.hearts <= 2 || danger || i.bossAlive;
const near = i.crystals.reduce(
(n, c) => n + (c.collected || Math.abs(c.x - i.playerX) > SHIMMER_RADIUS ? 0 : 1),
0,
);
return { urgent, shimmer: near >= SHIMMER_MIN };
}
108 changes: 101 additions & 7 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class AudioManager {
private musicNextT = 0; // AudioContext time of the next step (0 = unscheduled)
private musicStep = 0;

/* --- Adaptive layers: always scheduled, crossfaded by setAdaptive --- */
private urgentGain: GainNode | null = null; // drums that tense the run up
private shimmerGain: GainNode | null = null; // high pad that sparkles over crystal fields

constructor() {
this.muted = Store.get('tinyrex_muted', false);
}
Expand All @@ -58,6 +62,18 @@ export class AudioManager {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
this.setAdaptive(false, false); // let the layers settle when music stops
}

/**
* Crossfade the adaptive layers. The layers are scheduled continuously
* while music plays, so only the gains move — no audible clicks.
*/
setAdaptive(urgent: boolean, shimmer: boolean): void {
if (!this.ctx) return;
const t = this.ctx.currentTime;
if (this.urgentGain) this.urgentGain.gain.setTargetAtTime(urgent ? 0.9 : 0, t, 0.35);
if (this.shimmerGain) this.shimmerGain.gain.setTargetAtTime(shimmer ? 0.85 : 0, t, 0.5);
}

private scheduleMusic(): void {
Expand All @@ -68,32 +84,84 @@ export class AudioManager {
if (this.musicNextT === 0) this.musicNextT = this.ctx.currentTime + 0.12;
const lead = MUSIC[theme].lead;
const bass = MUSIC[theme].bass;
const drums = MUSIC[theme].drums;
while (this.musicNextT < this.ctx.currentTime + 0.15) {
if (!this.muted) {
const idx = this.musicStep % lead.length;
const ln = lead[idx];
if (ln > 0) this.musicNote(midiToFreq(ln), this.musicNextT, step * 0.9, 'square', 0.045);
const bn = bass[idx % bass.length];
if (bn > 0) this.musicNote(midiToFreq(bn), this.musicNextT, step * 0.9, 'triangle', 0.06);
// Urgent layer: per-theme drum pattern (1 = kick, 2 = hat).
const dn = drums[idx];
if (dn === 1) this.kick(this.musicNextT);
else if (dn === 2) this.hat(this.musicNextT);
// Shimmer layer: soft octave-up echo of the lead, plus a sparkle ping.
if (ln > 0) {
this.musicNote(midiToFreq(ln + 12), this.musicNextT, step * 1.7, 'sine', 0.05, this.shimmerGain);
if (idx % 8 === 4) {
this.musicNote(midiToFreq(ln + 24), this.musicNextT + step * 0.5, 0.4, 'sine', 0.06, this.shimmerGain);
}
}
}
this.musicStep++;
this.musicNextT += step;
}
}

private musicNote(freq: number, t0: number, dur: number, type: OscillatorType, vol: number): void {
private musicNote(
freq: number,
t0: number,
dur: number,
type: OscillatorType,
vol: number,
out?: GainNode | null,
): void {
const osc = this.ctx!.createOscillator();
const g = this.ctx!.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
osc.connect(g);
g.connect(this.master!);
g.connect(out ?? this.master!);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}

/* Urgent-layer drums: a low kick thump and a short hi-hat tick. */
private kick(t0: number): void {
const osc = this.ctx!.createOscillator();
const g = this.ctx!.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(130, t0);
osc.frequency.exponentialRampToValueAtTime(42, t0 + 0.12);
g.gain.setValueAtTime(0.5, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.13);
osc.connect(g);
g.connect(this.urgentGain!);
osc.start(t0);
osc.stop(t0 + 0.15);
}

private hat(t0: number): void {
const len = Math.max(1, Math.floor(this.ctx!.sampleRate * 0.045));
const buf = this.ctx!.createBuffer(1, len, this.ctx!.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = this.ctx!.createBufferSource();
src.buffer = buf;
const filt = this.ctx!.createBiquadFilter();
filt.type = 'highpass';
filt.frequency.value = 6000;
const g = this.ctx!.createGain();
g.gain.value = 0.3;
src.connect(filt);
filt.connect(g);
g.connect(this.urgentGain!);
src.start(t0);
}

unlock(): void {
if (this.ctx) {
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => undefined);
Expand All @@ -109,6 +177,12 @@ export class AudioManager {
this.master = this.ctx.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.ctx.destination);
this.urgentGain = this.ctx.createGain();
this.urgentGain.gain.value = 0;
this.urgentGain.connect(this.master);
this.shimmerGain = this.ctx.createGain();
this.shimmerGain.gain.value = 0;
this.shimmerGain.connect(this.master);
this.ensureAmbient();
} catch {
this.ctx = null;
Expand Down Expand Up @@ -277,11 +351,27 @@ export class AudioManager {
case 'rockfall':
this.noiseBurst({ dur: 0.12, vol: 0.12, freq: 500 });
break;
case 'victory':
[523, 659, 784, 1046, 784, 1046].forEach((f, i) =>
this.tone({ freq: f, dur: i === 5 ? 0.4 : 0.16, type: 'triangle', vol: 0.22, delay: i * 0.13 }),
case 'victory': {
// A flawless run (no damage, no deaths) earns the longer, brighter fanfare.
const seq = opts?.flawless
? [523, 659, 784, 1046, 1318, 1568, 1318, 2093]
: [523, 659, 784, 1046, 784, 1046];
seq.forEach((f, i) =>
this.tone({
freq: f,
dur: i === seq.length - 1 ? 0.5 : 0.16,
type: 'triangle',
vol: 0.22,
delay: i * (opts?.flawless ? 0.12 : 0.13),
}),
);
if (opts?.flawless) {
// High bell shimmer over the final notes
this.tone({ freq: 2637, dur: 0.4, type: 'sine', vol: 0.12, delay: 0.72 });
this.tone({ freq: 2093, dur: 0.4, type: 'sine', vol: 0.1, delay: 0.84 });
}
break;
}
case 'ui':
this.tone({ freq: 660, to: 880, dur: 0.07, type: 'square', vol: 0.08 });
break;
Expand Down Expand Up @@ -357,23 +447,27 @@ export class AudioManager {
}
}

/* Chiptune melodies per theme (MIDI note numbers, 0 = rest). 32 steps = 4 bars of 8ths. */
/* Chiptune melodies per theme (MIDI note numbers, 0 = rest). 32 steps = 4 bars of 8ths.
* drums: 0 = rest, 1 = kick, 2 = hat — the adaptive "urgent" layer. */
const midiToFreq = (n: number): number => 440 * Math.pow(2, (n - 69) / 12);

const MUSIC: Record<LevelTheme, { bpm: number; lead: number[]; bass: number[] }> = {
const MUSIC: Record<LevelTheme, { bpm: number; lead: number[]; bass: number[]; drums: number[] }> = {
meadow: {
bpm: 108,
lead: [69, 72, 76, 81, 76, 72, 69, 76, 66, 69, 73, 78, 81, 78, 73, 69, 69, 72, 76, 73, 72, 69, 66, 62, 64, 66, 69, 73, 72, 69, 64, 0],
bass: [45, 0, 57, 0, 45, 0, 57, 0, 42, 0, 54, 0, 42, 0, 54, 0, 38, 0, 50, 0, 38, 0, 50, 0, 40, 0, 52, 0, 40, 0, 52, 0],
drums: [1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2, 1, 0, 0, 2, 0, 0, 1, 2],
},
volcanic: {
bpm: 92,
lead: [57, 60, 64, 67, 64, 60, 57, 55, 54, 57, 60, 64, 60, 57, 54, 52, 55, 57, 60, 62, 60, 57, 55, 52, 52, 54, 57, 60, 57, 54, 52, 0],
bass: [33, 0, 45, 0, 33, 0, 45, 0, 30, 0, 42, 0, 30, 0, 42, 0, 31, 0, 43, 0, 31, 0, 43, 0, 40, 0, 52, 0, 40, 0, 52, 0],
drums: [1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2, 1, 0, 2, 1, 0, 2, 1, 2],
},
frost: {
bpm: 100,
lead: [72, 76, 79, 84, 79, 76, 72, 71, 69, 72, 76, 79, 76, 72, 69, 67, 69, 72, 76, 74, 72, 69, 67, 64, 66, 69, 72, 76, 72, 69, 66, 0],
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],
},
};
7 changes: 7 additions & 0 deletions src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface SfxOptions {
starIndex?: number;
/** Pressure plate just pressed (true) or released (false). */
pressed?: boolean;
/** Victory fanfare variant: true for a flawless run (no damage, no deaths). */
flawless?: boolean;
}

/** Minimal SFX surface that entities need from the audio manager. */
Expand Down Expand Up @@ -40,6 +42,11 @@ export interface GameCtx {
addStatus(msg: string, color?: string): void;
onPlayerDeath(): void;
onPlayerVictory(): void;
/**
* The player lost a heart (god mode and bubble saves do not count).
* Feeds the flawless-run tracking for the victory fanfare.
*/
onPlayerHit(): void;
/**
* The Magma King collapsed: bonus score, fanfare, and the nest gate
* latches open. Fired exactly once per boss death.
Expand Down
40 changes: 39 additions & 1 deletion src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily';
import { GhostRecorder, GhostPlayer } from './ghost';
import { drawDecor } from './decor';
import { Sprite, SKINS, skinUnlocked } from './sprite';
import { adaptiveFlags } from './adaptive';
import { drawPowerUpIcon, POWERUP_COLORS } from './powerup';
import type { PowerUpType } from './powerup';
import type { GameCtx } from './ctx';
Expand Down Expand Up @@ -108,6 +109,10 @@ export class Game implements GameCtx {
private star80Shown = false;
private star100Shown = false;
deaths = 0;
/** Hearts lost this run (god mode and bubble saves don't count). 0 = flawless. */
hits = 0;
private lastUrgent = false;
private lastShimmer = false;
particles: Particle[] = [];
texts: FloatingText[] = [];
status = { msg: '', color: '#fff', t: 0 };
Expand Down Expand Up @@ -331,6 +336,9 @@ export class Game implements GameCtx {
this.star100Shown = false;
this.bossSlain = false;
this.deaths = 0;
this.hits = 0;
this.lastUrgent = false;
this.lastShimmer = false;
this.elapsed = 0;
this.time = 0;
this.particles = [];
Expand Down Expand Up @@ -620,8 +628,36 @@ export class Game implements GameCtx {
}
}

/** The player lost a heart: counts against a flawless run. */
onPlayerHit(): void {
this.hits += 1;
}

/**
* Crossfade the adaptive music layers: drums tense up when hearts run low,
* hazards loom ahead, or the boss is in the arena; the shimmer pad sparkles
* over crystal-dense stretches. Called every frame while playing; the
* audio manager only moves the gains when a flag actually changed.
*/
private updateAdaptive(): void {
const p = this.player!;
const lvl = this.level!;
const flags = adaptiveFlags({
hearts: p.hearts,
playerX: p.x,
bossAlive: lvl.boss !== null && !lvl.boss.dead,
hazards: lvl.hazards,
crystals: lvl.crystals,
});
if (flags.urgent === this.lastUrgent && flags.shimmer === this.lastShimmer) return;
this.lastUrgent = flags.urgent;
this.lastShimmer = flags.shimmer;
this.audio.setAdaptive(flags.urgent, flags.shimmer);
}

onPlayerDeath(): void {
this.deaths += 1;
this.hits += 1;
const s = getStats();
s.deaths += 1;
Store.set('tinyrex_stats', s);
Expand All @@ -636,7 +672,8 @@ export class Game implements GameCtx {
this.victoryT = 0;
this.starChime = 0;
this.uiButtons = []; // no menu buttons linger during the celebration
this.audio.play('victory');
this.audio.play('victory', { flawless: this.hits === 0 });
this.audio.setAdaptive(false, false); // let the run's tension settle
this.addShake(4);
// Confetti from above the nest (molten palette when the boss fell)
const palette = this.bossSlain
Expand Down Expand Up @@ -750,6 +787,7 @@ export class Game implements GameCtx {
this.level!.update(dt, this.time, this.player!);
this.player!.update(dt, this.time, this.input, this.level!);
this.camera.update(dt, this.player!, this.level!.width, this);
this.updateAdaptive();
// track crystal count
this.crystalsGot = this.level!.crystals.filter((c) => c.collected).length;
// Combo expires once the window elapses without another pickup
Expand Down
1 change: 1 addition & 0 deletions src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export class Player {
return;
}
this.hearts -= 1;
this.game.onPlayerHit();
this.invulnT = CFG.player.invulnTime;
this.hurtT = 0.5;
this.game.audio.play('hurt');
Expand Down
Loading
Loading