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
2 changes: 1 addition & 1 deletion src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<nav class="app-nav">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">Home</a>
<a routerLink="/edit" routerLinkActive="active">Editor</a>
<a *ngIf="enableDevTools" routerLink="/edit" routerLinkActive="active">Editor</a>
<a *ngIf="enableDevTools" routerLink="/library" routerLinkActive="active">Library</a>
</nav>
<a class="settings-cog" aria-label="Settings" (click)="toggleSettings()">
Expand Down
5 changes: 2 additions & 3 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,14 @@ describe('AppComponent', () => {
expect(app.title).toEqual('Novarise');
});

it('renders Home and Editor nav links (non-dev build)', () => {
it('renders Home nav link only in a non-dev build (Editor and Library are dev-gated)', () => {
(environment as { enableDevTools: boolean }).enableDevTools = false;
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const navLinks = compiled.querySelectorAll('.app-nav a');
expect(navLinks.length).toBe(2);
expect(navLinks.length).toBe(1);
expect(navLinks[0].textContent).toContain('Home');
expect(navLinks[1].textContent).toContain('Editor');
const cog = compiled.querySelector('.settings-cog');
expect(cog).toBeTruthy();
expect(cog!.getAttribute('aria-label')).toBe('Settings');
Expand Down
22 changes: 22 additions & 0 deletions src/app/core/constants/music.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export const SCHEDULE_INTERVAL_MS = 200;
/** How far ahead the scheduler books beats, in seconds. */
export const SCHEDULE_HORIZON_SECONDS = 0.5;

/**
* If the scheduler falls further behind real time than this (e.g. the tab was
* backgrounded and setInterval was throttled), snap the next beat forward to
* "now" instead of booking every missed beat at once — otherwise all the
* missed beats fire in a single burst when the tab regains focus.
*/
export const MAX_SCHEDULER_CATCHUP_SECONDS = 0.5;

/**
* Default master music volume (0–1).
*
Expand Down Expand Up @@ -103,6 +111,20 @@ export const PLUCK_NOTE_DURATION_SECONDS = 0.2;
/** Volume ramp-up time for a pluck note, in seconds. */
export const PLUCK_ATTACK_SECONDS = 0.02;

/**
* Bass note attack, in seconds. The bass drone starts each bar at silence and
* ramps up over this window so the note onset does not click.
*/
export const BASS_ATTACK_SECONDS = 0.02;

/**
* Bass note release, in seconds. When the next bar replaces the bass drone, the
* outgoing note ramps to silence over this window starting at the bar boundary
* (rather than being hard-stopped on the scheduler's clock), which removes the
* per-bar click and the early cut-off caused by the lookahead scheduler.
*/
export const BASS_RELEASE_SECONDS = 0.08;

// ── HUB theme — calm, sparse ─────────────────────────────────────────────────

const HUB_PAD: PadLayerConfig = {
Expand Down
106 changes: 93 additions & 13 deletions src/app/core/services/music.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import {
MUSIC_GAIN_EPSILON,
SCHEDULE_INTERVAL_MS,
SCHEDULE_HORIZON_SECONDS,
MAX_SCHEDULER_CATCHUP_SECONDS,
PAD_FADE_IN_SECONDS,
PAD_FADE_OUT_SECONDS,
PLUCK_NOTE_DURATION_SECONDS,
PLUCK_ATTACK_SECONDS,
BASS_ATTACK_SECONDS,
BASS_RELEASE_SECONDS,
COMPRESSOR_THRESHOLD_DB,
COMPRESSOR_KNEE_DB,
COMPRESSOR_RATIO,
Expand Down Expand Up @@ -133,21 +136,50 @@ export class MusicService {
this.masterGain.gain.setValueAtTime(0, now);
}

// Stop all active theme schedulers
// Stop scheduling new beats immediately, then tear down the oscillator
// nodes. On a fade-out the teardown is deferred until the fade completes so
// the still-sounding voices fade with the master rather than hard-stopping
// (which would click); on an instant stop the master is already at 0 so the
// nodes can be stopped right away. Leaving the nodes running (the previous
// behaviour) made them re-emerge audibly when the next theme restored the
// master gain.
const themes = Object.keys(this.themeStates) as MusicTheme[];
for (const theme of themes) {
const state = this.themeStates[theme];
if (!state) continue;
this.stopThemeScheduler(theme);
if (fadeDuration > MUSIC_GAIN_EPSILON) {
this.scheduleDeferredTeardown(theme, state, fadeDuration * 1000);
} else {
this.stopThemeNodes(theme);
}
}

this.currentTheme = null;
this.pendingTheme = null;
}

/**
* Tear down a theme's nodes after `delayMs`, but only if that exact ThemeState
* is still the active one — a rapid round-trip back to the theme may have
* already replaced it via startTheme(), and that newer instance must survive.
*/
private scheduleDeferredTeardown(theme: MusicTheme, state: ThemeState, delayMs: number): void {
setTimeout(() => {
if (this.themeStates[theme] !== state) return;
this.stopThemeScheduler(theme);
this.stopThemeNodes(theme);
}, delayMs);
}

/** Update master music volume (0–1, clamped). */
setMusicVolume(volume: number): void {
this.musicVolume = Math.min(1, Math.max(0, volume));
if (this.masterGain && this.audioContext) {
const now = this.audioContext.currentTime;
// Clear any in-flight fade so the new level holds instead of resuming the
// old ramp toward its previous destination.
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.musicVolume, now);
}
}
Expand Down Expand Up @@ -253,16 +285,16 @@ export class MusicService {
if (oldState) {
oldState.gainNode.gain.setValueAtTime(oldState.gainNode.gain.value, now);
oldState.gainNode.gain.linearRampToValueAtTime(MUSIC_GAIN_EPSILON, now + fadeDuration);
// Stop old scheduler after the fade
const stopDelay = fadeDuration * 1000;
setTimeout(() => {
this.stopThemeScheduler(oldTheme);
this.stopThemeNodes(oldTheme);
}, stopDelay);
// Tear the old theme down after the fade — guarded so a rapid round-trip
// back to this same theme (which restarts it) is not torn down by this timer.
this.scheduleDeferredTeardown(oldTheme, oldState, fadeDuration * 1000);
}
}

// Ensure master gain is at current volume (may have been ramped to 0 by stopMusic)
// Restore master to current volume. cancelScheduledValues first clears any
// pending stopMusic() fade-to-zero ramp; otherwise that ramp keeps running
// and silences the theme we are about to start.
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.musicVolume, now);

// Start new theme
Expand Down Expand Up @@ -344,7 +376,16 @@ export class MusicService {
const state = this.themeStates[theme];
if (!state) return;

const horizon = this.audioContext.currentTime + SCHEDULE_HORIZON_SECONDS;
const nowTime = this.audioContext.currentTime;

// If the scheduler slipped far behind real time (backgrounded tab throttles
// setInterval), snap forward to now rather than booking every missed beat —
// otherwise they all fire at once as a burst when the tab regains focus.
if (state.nextBeatTime < nowTime - MAX_SCHEDULER_CATCHUP_SECONDS) {
state.nextBeatTime = nowTime;
}

const horizon = nowTime + SCHEDULE_HORIZON_SECONDS;

while (state.nextBeatTime < horizon) {
this.scheduleBeat(theme, state, state.nextBeatTime);
Expand Down Expand Up @@ -477,13 +518,19 @@ export class MusicService {
try { osc.stop(stopTime); } catch { /* already stopped */ }
}
try { pad.lfoOscillator.stop(stopTime); } catch { /* already stopped */ }

// Disconnect the layer's nodes once the fade completes so a long session
// does not accumulate stopped-but-connected nodes on the audio graph.
pad.lfoOscillator.onended = (): void => { this.stopPadLayer(pad); };
}

private stopPadLayer(pad: ActivePadLayer): void {
for (const osc of pad.oscillators) {
try { osc.stop(); } catch { /* already stopped */ }
try { osc.disconnect(); } catch { /* already disconnected */ }
}
try { pad.lfoOscillator.stop(); } catch { /* already stopped */ }
try { pad.lfoOscillator.disconnect(); } catch { /* already disconnected */ }
try { pad.gainNode.disconnect(); } catch { /* already disconnected */ }
try { pad.lfoGain.disconnect(); } catch { /* already disconnected */ }
try { pad.filter.disconnect(); } catch { /* already disconnected */ }
Expand All @@ -500,9 +547,13 @@ export class MusicService {
if (this.audioContext === null) return;
const config = state.config;

// Stop previous bass
// Release the previous bass at the bar boundary with a short fade. A hard
// stop here clicks, and because the scheduler books beats up to
// SCHEDULE_HORIZON_SECONDS ahead, an immediate stop also cuts the still-
// sounding note off early. Fading at `startTime` keeps it click-free and
// musically aligned.
for (const bass of state.bassLayers) {
this.stopBassLayer(bass);
this.fadeBassOut(bass, startTime);
}
state.bassLayers = [];

Expand All @@ -521,8 +572,10 @@ export class MusicService {
osc.type = 'sine';
osc.frequency.setValueAtTime(bassHz, startTime);

// Attack ramp from silence avoids the onset click of an instant full-gain start.
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(config.bassLayer.gain, startTime);
gainNode.gain.setValueAtTime(MUSIC_GAIN_EPSILON, startTime);
gainNode.gain.linearRampToValueAtTime(config.bassLayer.gain, startTime + BASS_ATTACK_SECONDS);

osc.connect(gainNode);
gainNode.connect(state.gainNode);
Expand All @@ -534,6 +587,27 @@ export class MusicService {
void theme;
}

/**
* Fade a bass note to silence over BASS_RELEASE_SECONDS starting at `atTime`
* (the bar boundary), then stop and disconnect its nodes once the fade ends.
* Used for the per-bar bass transition; `stopBassLayer` remains the immediate
* teardown path used on theme stop / cleanup.
*/
private fadeBassOut(bass: ActiveBassLayer, atTime: number): void {
const ctx = this.audioContext;
if (!ctx) return;

bass.gainNode.gain.setValueAtTime(bass.gainNode.gain.value, atTime);
bass.gainNode.gain.linearRampToValueAtTime(MUSIC_GAIN_EPSILON, atTime + BASS_RELEASE_SECONDS);

const stopTime = atTime + BASS_RELEASE_SECONDS + 0.02;
try { bass.oscillator.stop(stopTime); } catch { /* already stopped */ }
bass.oscillator.onended = (): void => {
try { bass.oscillator.disconnect(); } catch { /* already disconnected */ }
try { bass.gainNode.disconnect(); } catch { /* already disconnected */ }
};
}

private stopBassLayer(bass: ActiveBassLayer): void {
try { bass.oscillator.stop(); } catch { /* already stopped */ }
try { bass.gainNode.disconnect(); } catch { /* already disconnected */ }
Expand Down Expand Up @@ -572,7 +646,9 @@ export class MusicService {
config.pluckLayer.gain,
beatTime + PLUCK_ATTACK_SECONDS,
);
gainNode.gain.linearRampToValueAtTime(
// Exponential decay from the (positive) peak reads as a more natural pluck
// tail than a linear ramp, which holds too much energy near the end.
gainNode.gain.exponentialRampToValueAtTime(
MUSIC_GAIN_EPSILON,
beatTime + PLUCK_NOTE_DURATION_SECONDS,
);
Expand All @@ -581,6 +657,10 @@ export class MusicService {
gainNode.connect(state.gainNode);
osc.start(beatTime);
osc.stop(beatTime + PLUCK_NOTE_DURATION_SECONDS + 0.02);
osc.onended = (): void => {
try { osc.disconnect(); } catch { /* already disconnected */ }
try { gainNode.disconnect(); } catch { /* already disconnected */ }
};
}

// ── Private: Utility ─────────────────────────────────────────────────────
Expand Down
55 changes: 55 additions & 0 deletions src/app/core/services/settings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,59 @@ describe('SettingsService', () => {
const fresh = service.get();
expect(fresh.audioMuted).toBe(false); // original unaffected
});

// ── applyReduceMotion ─────────────────────────────────────────────────

describe('applyReduceMotion', () => {
afterEach(() => {
document.body.classList.remove('reduce-motion');
});

it('adds reduce-motion class to body when enabled', () => {
service.applyReduceMotion(true);
expect(document.body.classList.contains('reduce-motion')).toBeTrue();
});

it('removes reduce-motion class from body when disabled', () => {
document.body.classList.add('reduce-motion');
service.applyReduceMotion(false);
expect(document.body.classList.contains('reduce-motion')).toBeFalse();
});

it('applies reduce-motion class on construction when saved setting is true', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
audioMuted: false, musicEnabled: true, musicVolume: 0.5,
difficulty: 'normal', showFps: false, reduceMotion: true,
colorblindAssist: false, fontScale: 1,
}));
const fresh = new SettingsService(new StorageService());
expect(fresh.get().reduceMotion).toBeTrue();
expect(document.body.classList.contains('reduce-motion')).toBeTrue();
});

it('does not add reduce-motion class on construction when saved setting is false', () => {
document.body.classList.remove('reduce-motion');
const fresh = new SettingsService(new StorageService());
expect(fresh.get().reduceMotion).toBeFalse();
expect(document.body.classList.contains('reduce-motion')).toBeFalse();
});

it('toggles body class when update changes reduceMotion to true', () => {
expect(document.body.classList.contains('reduce-motion')).toBeFalse();
service.update({ reduceMotion: true });
expect(document.body.classList.contains('reduce-motion')).toBeTrue();
});

it('removes body class when update changes reduceMotion to false', () => {
service.update({ reduceMotion: true });
service.update({ reduceMotion: false });
expect(document.body.classList.contains('reduce-motion')).toBeFalse();
});

it('does not alter reduce-motion class when update does not touch reduceMotion', () => {
document.body.classList.remove('reduce-motion');
service.update({ audioMuted: true });
expect(document.body.classList.contains('reduce-motion')).toBeFalse();
});
});
});
21 changes: 21 additions & 0 deletions src/app/core/services/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class SettingsService {
constructor(private storageService: StorageService) {
this.settings = this.load();
this.applyFontScale(this.settings.fontScale);
this.applyReduceMotion(this.settings.reduceMotion);
}

/**
Expand All @@ -49,12 +50,32 @@ export class SettingsService {
if (cls) html.classList.add(cls);
}

/**
* Toggle the `reduce-motion` class on <body> so CSS animation-suppression
* rules take effect immediately — regardless of whether the OS-level
* `prefers-reduced-motion` media query is set.
* Called on boot and whenever the user changes the setting.
*/
applyReduceMotion(enabled: boolean): void {
if (enabled) {
document.body.classList.add('reduce-motion');
} else {
document.body.classList.remove('reduce-motion');
}
}

get(): GameSettings {
return { ...this.settings };
}

update(partial: Partial<GameSettings>): void {
this.settings = { ...this.settings, ...partial };
if (partial.fontScale !== undefined) {
this.applyFontScale(this.settings.fontScale);
}
if (partial.reduceMotion !== undefined) {
this.applyReduceMotion(this.settings.reduceMotion);
}
this.save();
}

Expand Down
14 changes: 14 additions & 0 deletions src/app/core/services/tutorial.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,4 +699,18 @@ describe('TutorialService', () => {
});
});
});

describe('SELECT_TOWER tip content', () => {
it('SELECT_TOWER message mentions that gold does not reset between encounters', () => {
const tip = service.getTip(TutorialStep.SELECT_TOWER);
// The message should explain run-persistent gold so new players understand
// the unified gold model (gold carries forward into shops, rest sites, next fight).
expect(tip.message).toContain('does NOT reset between encounters');
});

it('SELECT_TOWER tip targets the energy UI element', () => {
const tip = service.getTip(TutorialStep.SELECT_TOWER);
expect(tip.targetSelector).toBe('.card-hand__energy');
});
});
});
2 changes: 1 addition & 1 deletion src/app/core/services/tutorial.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const TUTORIAL_TIPS: Record<TutorialStep, TutorialTip> = {
step: TutorialStep.SELECT_TOWER,
type: 'tutorial',
title: 'Your Hand & Energy',
message: 'Your cards appear at the bottom of the screen. Tower cards have a dual cost — energy (shown top-left of the hand) AND gold. Check your gold bar before committing a tower; you refill energy each turn but gold must be earned.',
message: 'Your cards appear at the bottom of the screen. Tower cards have a dual cost — energy (shown top-left of the hand) AND gold. You refill energy each turn, but gold does NOT reset between encounters — it carries forward into shops, rest sites, and the next fight, so spend carefully.',
targetSelector: '.card-hand__energy',
position: 'bottom',
},
Expand Down
Loading
Loading