diff --git a/public/assets/themes/default/audio/voice/first_collision_hint.mp3 b/public/assets/themes/default/audio/voice/first_collision_hint.mp3 new file mode 100644 index 0000000..916e3b8 Binary files /dev/null and b/public/assets/themes/default/audio/voice/first_collision_hint.mp3 differ diff --git a/public/assets/themes/default/audio/voice/fuel_usage_hint.mp3 b/public/assets/themes/default/audio/voice/fuel_usage_hint.mp3 new file mode 100644 index 0000000..4101310 Binary files /dev/null and b/public/assets/themes/default/audio/voice/fuel_usage_hint.mp3 differ diff --git a/public/assets/themes/default/audio/voice/nice_shot.mp3 b/public/assets/themes/default/audio/voice/nice_shot.mp3 new file mode 100644 index 0000000..b5c7e93 Binary files /dev/null and b/public/assets/themes/default/audio/voice/nice_shot.mp3 differ diff --git a/public/assets/themes/default/audio/voice/welcome_rookie.mp3 b/public/assets/themes/default/audio/voice/welcome_rookie.mp3 index 47bee00..8e70959 100644 Binary files a/public/assets/themes/default/audio/voice/welcome_rookie.mp3 and b/public/assets/themes/default/audio/voice/welcome_rookie.mp3 differ diff --git a/src/levels/hints/levelHintSystem.ts b/src/levels/hints/levelHintSystem.ts new file mode 100644 index 0000000..c63e721 --- /dev/null +++ b/src/levels/hints/levelHintSystem.ts @@ -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 = new Set(); + private _audioQueue: StaticSound[] = []; + private _isPlaying: boolean = false; + private _asteroidsDestroyed: number = 0; + + // Observers for cleanup + private _statusObserver: Observer | null = null; + private _scoreObserver: Observer | null = null; + private _collisionObserver: Observer | null = null; + + // Track triggered thresholds to prevent re-triggering + private _triggeredThresholds: Set = new Set(); + + constructor(audioEngine: AudioEngineV2) { + this._audioEngine = audioEngine; + } + + /** + * Load hints for a level from database + */ + public async loadHints(levelId: string): Promise { + 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, + collisionObservable?: Observable + ): 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'); + } +} diff --git a/src/levels/level1.ts b/src/levels/level1.ts index 3721915..9cfe6b5 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -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 diff --git a/src/services/hintService.ts b/src/services/hintService.ts new file mode 100644 index 0000000..460036e --- /dev/null +++ b/src/services/hintService.ts @@ -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; + 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; + 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 { + 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); + } +} diff --git a/src/ship/ship.ts b/src/ship/ship.ts index 95976a0..a2b9b6f 100644 --- a/src/ship/ship.ts +++ b/src/ship/ship.ts @@ -63,6 +63,9 @@ export class Ship { // Observable for mission brief trigger dismissal private _onMissionBriefTriggerObservable: Observable = new Observable(); + // 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' + }); } } }); diff --git a/supabase/schema.sql b/supabase/schema.sql index da48ecf..9b33cb7 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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; +