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 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
|
||||
|
||||
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
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user