Add level hints system for event-triggered audio playback
Some checks failed
Build / build (push) Has been cancelled

Implements a hint system that plays audio clips when specific game events occur:
- Ship status changes (fuel/hull/ammo thresholds)
- Asteroid destruction counts
- Ship collisions

Hints are stored in database with configurable play modes (once/always).
Also lowers background music volume from 0.5 to 0.2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-01 08:45:20 -06:00
parent e3422ef9f2
commit 91e712edd9
9 changed files with 366 additions and 1 deletions

Binary file not shown.

View File

@ -0,0 +1,218 @@
import type { AudioEngineV2, StaticSound, Observable, Observer } from "@babylonjs/core";
import { HintService, HintEntry } from "../../services/hintService";
import { ShipStatus, ShipStatusChangeEvent } from "../../ship/shipStatus";
import { ScoreEvent } from "../../ui/hud/scoreboard";
import log from "../../core/logger";
/**
* Collision event for hint triggers
*/
export interface CollisionEvent {
collisionType: string;
}
/**
* Manages level-specific hint audio playback
* Loads hints from database and triggers audio based on game events
*/
export class LevelHintSystem {
private _audioEngine: AudioEngineV2;
private _hints: HintEntry[] = [];
private _playedHints: Set<string> = new Set();
private _audioQueue: StaticSound[] = [];
private _isPlaying: boolean = false;
private _asteroidsDestroyed: number = 0;
// Observers for cleanup
private _statusObserver: Observer<ShipStatusChangeEvent> | null = null;
private _scoreObserver: Observer<ScoreEvent> | null = null;
private _collisionObserver: Observer<CollisionEvent> | null = null;
// Track triggered thresholds to prevent re-triggering
private _triggeredThresholds: Set<string> = new Set();
constructor(audioEngine: AudioEngineV2) {
this._audioEngine = audioEngine;
}
/**
* Load hints for a level from database
*/
public async loadHints(levelId: string): Promise<void> {
const hintService = HintService.getInstance();
this._hints = await hintService.getHintsForLevel(levelId);
log.info('[LevelHintSystem] Loaded', this._hints.length, 'hints');
}
/**
* Subscribe to game events to trigger hints
*/
public subscribeToEvents(
shipStatus: ShipStatus,
scoreObservable: Observable<ScoreEvent>,
collisionObservable?: Observable<CollisionEvent>
): void {
// Ship status changes (fuel, hull, ammo)
this._statusObserver = shipStatus.onStatusChanged.add((event) => {
this.handleStatusChange(event);
});
// Asteroid destroyed events
this._scoreObserver = scoreObservable.add((event) => {
this.handleScoreEvent(event);
});
// Collision events (optional)
if (collisionObservable) {
this._collisionObserver = collisionObservable.add((event) => {
this.handleCollision(event);
});
}
log.info('[LevelHintSystem] Subscribed to events');
}
/**
* Handle ship status changes (fuel/hull/ammo thresholds)
*/
private handleStatusChange(event: ShipStatusChangeEvent): void {
const hints = this._hints.filter(h =>
h.eventType === 'ship_status' &&
h.eventConfig.status_type === event.statusType
);
for (const hint of hints) {
const threshold = hint.eventConfig.threshold as number;
const direction = hint.eventConfig.direction as string;
const thresholdKey = `${hint.id}_${threshold}_${direction}`;
// Check if threshold crossed
let triggered = false;
if (direction === 'below') {
// Trigger when crossing below threshold
if (event.oldValue > threshold && event.newValue <= threshold) {
triggered = true;
}
} else if (direction === 'above') {
// Trigger when crossing above threshold
if (event.oldValue < threshold && event.newValue >= threshold) {
triggered = true;
}
}
if (triggered) {
// For 'always' mode, check if we've already triggered this threshold
if (hint.playMode === 'always') {
if (this._triggeredThresholds.has(thresholdKey)) {
continue; // Already triggered this session
}
this._triggeredThresholds.add(thresholdKey);
}
this.queueHint(hint);
}
}
}
/**
* Handle asteroid destroyed events
*/
private handleScoreEvent(event: ScoreEvent): void {
if (event.score > 0) {
this._asteroidsDestroyed++;
const hints = this._hints.filter(h =>
h.eventType === 'asteroid_destroyed' &&
h.eventConfig.count === this._asteroidsDestroyed
);
for (const hint of hints) {
this.queueHint(hint);
}
}
}
/**
* Handle collision events
*/
private handleCollision(event: CollisionEvent): void {
const hints = this._hints.filter(h => {
if (h.eventType !== 'collision') return false;
const collisionType = h.eventConfig.collision_type as string;
return collisionType === 'any' || collisionType === event.collisionType;
});
for (const hint of hints) {
this.queueHint(hint);
}
}
/**
* Queue a hint for audio playback
*/
private queueHint(hint: HintEntry): void {
// Check if 'once' hint already played
if (hint.playMode === 'once' && this._playedHints.has(hint.id)) {
return;
}
// Mark as played
if (hint.playMode === 'once') {
this._playedHints.add(hint.id);
}
log.info('[LevelHintSystem] Queueing hint:', hint.id, hint.audioUrl);
// Load and queue audio
this._audioEngine.createSoundAsync(
`hint_${hint.id}_${Date.now()}`,
hint.audioUrl,
{ loop: false, volume: 2.0 }
).then(sound => {
this._audioQueue.push(sound);
}).catch(err => {
log.error('[LevelHintSystem] Failed to load audio:', hint.audioUrl, err);
});
}
/**
* Process audio queue - call from game update loop
*/
public update(): void {
if (!this._isPlaying && this._audioQueue.length > 0) {
const sound = this._audioQueue.shift()!;
this._isPlaying = true;
sound.onEndedObservable.add(() => {
this._isPlaying = false;
sound.dispose();
});
sound.play();
}
}
/**
* Clean up resources and unsubscribe from events
*/
public dispose(): void {
// Clear audio queue
for (const sound of this._audioQueue) {
sound.dispose();
}
this._audioQueue = [];
// Clear state
this._hints = [];
this._playedHints.clear();
this._triggeredThresholds.clear();
this._asteroidsDestroyed = 0;
this._isPlaying = false;
// Note: Observers are cleaned up when the observables are disposed
this._statusObserver = null;
this._scoreObserver = null;
this._collisionObserver = null;
log.info('[LevelHintSystem] Disposed');
}
}

View File

@ -20,6 +20,7 @@ import {MissionBrief} from "../ui/hud/missionBrief";
import {LevelRegistry} from "./storage/levelRegistry";
import type {CloudLevelEntry} from "../services/cloudLevelService";
import { InputControlManager } from "../ship/input/inputControlManager";
import { LevelHintSystem } from "./hints/levelHintSystem";
export class Level1 implements Level {
private _ship: Ship;
@ -36,6 +37,7 @@ export class Level1 implements Level {
private _isReplayMode: boolean;
private _backgroundMusic: StaticSound;
private _missionBrief: MissionBrief;
private _hintSystem: LevelHintSystem;
private _gameStarted: boolean = false;
private _missionBriefShown: boolean = false;
@ -47,6 +49,7 @@ export class Level1 implements Level {
this._deserializer = new LevelDeserializer(levelConfig);
this._ship = new Ship(audioEngine, isReplayMode);
this._missionBrief = new MissionBrief();
this._hintSystem = new LevelHintSystem(audioEngine);
// Only set up XR observables in game mode (not replay mode)
if (!isReplayMode && DefaultScene.XR) {
@ -339,6 +342,9 @@ export class Level1 implements Level {
if (this._missionBrief) {
this._missionBrief.dispose();
}
if (this._hintSystem) {
this._hintSystem.dispose();
}
if (this._ship) {
this._ship.dispose();
}
@ -398,6 +404,7 @@ export class Level1 implements Level {
});
// Set up camera follow for stars (keeps stars at infinite distance)
// Also update hint system audio queue
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
if (this._backgroundStars) {
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
@ -405,6 +412,8 @@ export class Level1 implements Level {
this._backgroundStars.followCamera(camera.position);
}
}
// Process hint audio queue
this._hintSystem?.update();
});
// Initialize physics recorder (but don't start it yet - will start on XR pose)
@ -420,7 +429,7 @@ export class Level1 implements Level {
setLoadingMessage("Loading background music...");
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", {
loop: true,
volume: 0.5
volume: 0.2
});
log.debug('Background music loaded successfully');
}
@ -435,6 +444,21 @@ export class Level1 implements Level {
log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
log.debug('Mission brief initialized');
// Initialize hint system (need UUID from registry, not slug)
if (this._levelId) {
const registry = LevelRegistry.getInstance();
const registryEntry = registry.getAllLevels().get(this._levelId);
if (registryEntry?.id) {
await this._hintSystem.loadHints(registryEntry.id);
this._hintSystem.subscribeToEvents(
this._ship.scoreboard.shipStatus,
this._ship.scoreboard.onScoreObservable,
this._ship.onCollisionObservable
);
log.info('[Level1] Hint system initialized with level UUID:', registryEntry.id);
}
}
this._initialized = true;
// Set par time and level info for score calculation and results recording

View File

@ -0,0 +1,84 @@
import { SupabaseService } from './supabaseService';
import log from '../core/logger';
/**
* Hint entry from the database
*/
export interface HintEntry {
id: string;
levelId: string;
eventType: 'ship_status' | 'asteroid_destroyed' | 'collision';
eventConfig: Record<string, unknown>;
audioUrl: string;
playMode: 'once' | 'always';
sortOrder: number;
}
/**
* Database row format (snake_case)
*/
interface HintRow {
id: string;
level_id: string;
event_type: string;
event_config: Record<string, unknown>;
audio_url: string;
play_mode: string;
sort_order: number;
}
/**
* Convert database row to HintEntry
*/
function rowToEntry(row: HintRow): HintEntry {
return {
id: row.id,
levelId: row.level_id,
eventType: row.event_type as HintEntry['eventType'],
eventConfig: row.event_config || {},
audioUrl: row.audio_url,
playMode: row.play_mode as HintEntry['playMode'],
sortOrder: row.sort_order,
};
}
/**
* Service for fetching level hints from Supabase
*/
export class HintService {
private static _instance: HintService;
private constructor() {}
public static getInstance(): HintService {
if (!HintService._instance) {
HintService._instance = new HintService();
}
return HintService._instance;
}
/**
* Get all hints for a level, ordered by sort_order
*/
public async getHintsForLevel(levelId: string): Promise<HintEntry[]> {
const client = SupabaseService.getInstance().getClient();
if (!client) {
log.warn('[HintService] Supabase not configured');
return [];
}
const { data, error } = await client
.from('hints')
.select('*')
.eq('level_id', levelId)
.order('sort_order', { ascending: true });
if (error) {
log.error('[HintService] Failed to fetch hints:', error);
return [];
}
log.info('[HintService] Loaded', data?.length || 0, 'hints for level', levelId);
return (data || []).map(rowToEntry);
}
}

View File

@ -63,6 +63,9 @@ export class Ship {
// Observable for mission brief trigger dismissal
private _onMissionBriefTriggerObservable: Observable<void> = new Observable<void>();
// Observable for collision events (for hint system)
private _onCollisionObservable: Observable<{ collisionType: string }> = new Observable<{ collisionType: string }>();
// Auto-show status screen flag
private _statusScreenAutoShown: boolean = false;
@ -112,6 +115,10 @@ export class Ship {
return this._onMissionBriefTriggerObservable;
}
public get onCollisionObservable(): Observable<{ collisionType: string }> {
return this._onCollisionObservable;
}
public get velocity(): Vector3 {
if (this._ship?.physicsBody) {
return this._ship.physicsBody.getLinearVelocity();
@ -249,6 +256,11 @@ export class Ship {
if (this._audio) {
this._audio.playCollisionSound();
}
// Notify collision observable for hint system
this._onCollisionObservable.notifyObservers({
collisionType: 'any'
});
}
}
});

View File

@ -227,3 +227,30 @@ grant delete, insert, references, select, trigger, truncate, update on public.us
grant delete, insert, references, select, trigger, truncate, update on public.users to service_role;
create table public.hints
(
id uuid default gen_random_uuid() not null
primary key,
level_id uuid not null
references public.levels
on delete cascade,
event_type text not null,
event_config jsonb default '{}'::jsonb not null,
audio_url text not null,
play_mode text default 'once'::text not null,
sort_order integer default 0,
created_at timestamp with time zone default now()
);
alter table public.hints
owner to postgres;
create index idx_hints_level
on public.hints (level_id);
grant select on public.hints to anon;
grant select on public.hints to authenticated;
grant delete, insert, references, select, trigger, truncate, update on public.hints to service_role;