Add level hints system for event-triggered audio playback
Some checks failed
Build / build (push) Has been cancelled
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:
parent
e3422ef9f2
commit
91e712edd9
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/fuel_usage_hint.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/fuel_usage_hint.mp3
Normal file
Binary file not shown.
BIN
public/assets/themes/default/audio/voice/nice_shot.mp3
Normal file
BIN
public/assets/themes/default/audio/voice/nice_shot.mp3
Normal file
Binary file not shown.
Binary file not shown.
218
src/levels/hints/levelHintSystem.ts
Normal file
218
src/levels/hints/levelHintSystem.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import {MissionBrief} from "../ui/hud/missionBrief";
|
|||||||
import {LevelRegistry} from "./storage/levelRegistry";
|
import {LevelRegistry} from "./storage/levelRegistry";
|
||||||
import type {CloudLevelEntry} from "../services/cloudLevelService";
|
import type {CloudLevelEntry} from "../services/cloudLevelService";
|
||||||
import { InputControlManager } from "../ship/input/inputControlManager";
|
import { InputControlManager } from "../ship/input/inputControlManager";
|
||||||
|
import { LevelHintSystem } from "./hints/levelHintSystem";
|
||||||
|
|
||||||
export class Level1 implements Level {
|
export class Level1 implements Level {
|
||||||
private _ship: Ship;
|
private _ship: Ship;
|
||||||
@ -36,6 +37,7 @@ export class Level1 implements Level {
|
|||||||
private _isReplayMode: boolean;
|
private _isReplayMode: boolean;
|
||||||
private _backgroundMusic: StaticSound;
|
private _backgroundMusic: StaticSound;
|
||||||
private _missionBrief: MissionBrief;
|
private _missionBrief: MissionBrief;
|
||||||
|
private _hintSystem: LevelHintSystem;
|
||||||
private _gameStarted: boolean = false;
|
private _gameStarted: boolean = false;
|
||||||
private _missionBriefShown: boolean = false;
|
private _missionBriefShown: boolean = false;
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ export class Level1 implements Level {
|
|||||||
this._deserializer = new LevelDeserializer(levelConfig);
|
this._deserializer = new LevelDeserializer(levelConfig);
|
||||||
this._ship = new Ship(audioEngine, isReplayMode);
|
this._ship = new Ship(audioEngine, isReplayMode);
|
||||||
this._missionBrief = new MissionBrief();
|
this._missionBrief = new MissionBrief();
|
||||||
|
this._hintSystem = new LevelHintSystem(audioEngine);
|
||||||
|
|
||||||
// Only set up XR observables in game mode (not replay mode)
|
// Only set up XR observables in game mode (not replay mode)
|
||||||
if (!isReplayMode && DefaultScene.XR) {
|
if (!isReplayMode && DefaultScene.XR) {
|
||||||
@ -339,6 +342,9 @@ export class Level1 implements Level {
|
|||||||
if (this._missionBrief) {
|
if (this._missionBrief) {
|
||||||
this._missionBrief.dispose();
|
this._missionBrief.dispose();
|
||||||
}
|
}
|
||||||
|
if (this._hintSystem) {
|
||||||
|
this._hintSystem.dispose();
|
||||||
|
}
|
||||||
if (this._ship) {
|
if (this._ship) {
|
||||||
this._ship.dispose();
|
this._ship.dispose();
|
||||||
}
|
}
|
||||||
@ -398,6 +404,7 @@ export class Level1 implements Level {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set up camera follow for stars (keeps stars at infinite distance)
|
// Set up camera follow for stars (keeps stars at infinite distance)
|
||||||
|
// Also update hint system audio queue
|
||||||
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
DefaultScene.MainScene.onBeforeRenderObservable.add(() => {
|
||||||
if (this._backgroundStars) {
|
if (this._backgroundStars) {
|
||||||
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
const camera = DefaultScene.XR?.baseExperience?.camera || DefaultScene.MainScene.activeCamera;
|
||||||
@ -405,6 +412,8 @@ export class Level1 implements Level {
|
|||||||
this._backgroundStars.followCamera(camera.position);
|
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)
|
// 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...");
|
setLoadingMessage("Loading background music...");
|
||||||
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", {
|
this._backgroundMusic = await this._audioEngine.createSoundAsync("background", "/assets/themes/default/audio/song1.mp3", {
|
||||||
loop: true,
|
loop: true,
|
||||||
volume: 0.5
|
volume: 0.2
|
||||||
});
|
});
|
||||||
log.debug('Background music loaded successfully');
|
log.debug('Background music loaded successfully');
|
||||||
}
|
}
|
||||||
@ -435,6 +444,21 @@ export class Level1 implements Level {
|
|||||||
log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
|
log.info('[Level1] ========== MISSION BRIEF INITIALIZATION COMPLETE ==========');
|
||||||
log.debug('Mission brief initialized');
|
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;
|
this._initialized = true;
|
||||||
|
|
||||||
// Set par time and level info for score calculation and results recording
|
// Set par time and level info for score calculation and results recording
|
||||||
|
|||||||
84
src/services/hintService.ts
Normal file
84
src/services/hintService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -63,6 +63,9 @@ export class Ship {
|
|||||||
// Observable for mission brief trigger dismissal
|
// Observable for mission brief trigger dismissal
|
||||||
private _onMissionBriefTriggerObservable: Observable<void> = new Observable<void>();
|
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
|
// Auto-show status screen flag
|
||||||
private _statusScreenAutoShown: boolean = false;
|
private _statusScreenAutoShown: boolean = false;
|
||||||
|
|
||||||
@ -112,6 +115,10 @@ export class Ship {
|
|||||||
return this._onMissionBriefTriggerObservable;
|
return this._onMissionBriefTriggerObservable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get onCollisionObservable(): Observable<{ collisionType: string }> {
|
||||||
|
return this._onCollisionObservable;
|
||||||
|
}
|
||||||
|
|
||||||
public get velocity(): Vector3 {
|
public get velocity(): Vector3 {
|
||||||
if (this._ship?.physicsBody) {
|
if (this._ship?.physicsBody) {
|
||||||
return this._ship.physicsBody.getLinearVelocity();
|
return this._ship.physicsBody.getLinearVelocity();
|
||||||
@ -249,6 +256,11 @@ export class Ship {
|
|||||||
if (this._audio) {
|
if (this._audio) {
|
||||||
this._audio.playCollisionSound();
|
this._audio.playCollisionSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify collision observable for hint system
|
||||||
|
this._onCollisionObservable.notifyObservers({
|
||||||
|
collisionType: 'any'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
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;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user