diff --git a/index.html b/index.html
index 8cae329e6..c01e6a85c 100644
--- a/index.html
+++ b/index.html
@@ -244,6 +244,11 @@
+
+
+
+
+
diff --git a/src/game.js b/src/game.js
index 42dbd4709..6a1ba3ce0 100644
--- a/src/game.js
+++ b/src/game.js
@@ -68,7 +68,7 @@ const config = {
debug: false
}
},
- scene: [BootScene, PreloadScene, SystemsTestScene, DemoScene, DemoSceneEnhanced, TiledTestScene, StoryScene, /* PrologueScene - DISABLED */, GameScene, UIScene, TownSquareScene],
+ scene: [BootScene, PreloadScene, SystemsTestScene, TestVisualAudioScene, DemoScene, DemoSceneEnhanced, TiledTestScene, StoryScene, /* PrologueScene - DISABLED */, GameScene, UIScene, TownSquareScene],
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
diff --git a/src/scenes/TestVisualAudioScene.js b/src/scenes/TestVisualAudioScene.js
new file mode 100644
index 000000000..f7a2624b0
--- /dev/null
+++ b/src/scenes/TestVisualAudioScene.js
@@ -0,0 +1,321 @@
+/**
+ * TestVisualAudioScene.js
+ * Demo scene: Kai speaks, dreadlocks wave, leaves fall
+ */
+
+class TestVisualAudioScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'TestVisualAudioScene' });
+ }
+
+ preload() {
+ console.log('๐ฌ Loading Test Visual & Audio Scene...');
+
+ // Load Kai voice
+ this.load.audio('kai_test_voice', 'assets/audio/voices/kai/kai_test_01.mp3');
+
+ // Load music (if exists)
+ if (this.cache.audio.exists('music/forest_ambient')) {
+ console.log('โ
Music ready');
+ }
+
+ // Load Kai sprite (placeholder for now)
+ // this.load.image('kai_idle', 'assets/references/main_characters/kai/animations/idle/kai_idle_frame1.png');
+ }
+
+ create() {
+ console.log('๐จ Creating Test Scene...');
+
+ const width = this.cameras.main.width;
+ const height = this.cameras.main.height;
+
+ // Background
+ this.cameras.main.setBackgroundColor('#7cfc00'); // Grassland green
+
+ // Title
+ const title = this.add.text(width / 2, 50, '๐จ VISUAL & AUDIO TEST', {
+ fontSize: '32px',
+ fontFamily: 'Arial',
+ color: '#ffffff',
+ stroke: '#000000',
+ strokeThickness: 4
+ });
+ title.setOrigin(0.5);
+ title.setDepth(1000);
+
+ // Instructions
+ const instructions = this.add.text(width / 2, 100,
+ 'Use WASD to move Kai\nStep on YELLOW TILE to trigger voice\nWatch dreadlocks wave in wind\nWatch leaves fall', {
+ fontSize: '16px',
+ fontFamily: 'Arial',
+ color: '#ffffff',
+ stroke: '#000000',
+ strokeThickness: 3,
+ align: 'center'
+ });
+ instructions.setOrigin(0.5);
+ instructions.setDepth(1000);
+
+ // Create ground tiles
+ this.createGround();
+
+ // Create Kai (simple placeholder)
+ this.createKai();
+
+ // Initialize systems
+ this.initSystems();
+
+ // Create falling leaves
+ this.createFallingLeaves();
+
+ // Create wind effect on Kai's dreadlocks
+ this.createWindEffect();
+
+ // Camera follow
+ this.cameras.main.startFollow(this.kai, true, 0.1, 0.1);
+ this.cameras.main.setZoom(1.2);
+
+ // Controls
+ this.cursors = this.input.keyboard.createCursorKeys();
+ this.wasd = {
+ up: this.input.keyboard.addKey('W'),
+ down: this.input.keyboard.addKey('S'),
+ left: this.input.keyboard.addKey('A'),
+ right: this.input.keyboard.addKey('D')
+ };
+
+ console.log('โ
Test Scene Ready!');
+ }
+
+ createGround() {
+ // Simple grass tiles
+ const tileSize = 48;
+ const gridWidth = 20;
+ const gridHeight = 15;
+
+ for (let y = 0; y < gridHeight; y++) {
+ for (let x = 0; x < gridWidth; x++) {
+ const worldX = x * tileSize;
+ const worldY = y * tileSize;
+
+ // Grass tile (light/dark alternating)
+ const isLight = (x + y) % 2 === 0;
+ const color = isLight ? 0x7cfc00 : 0x6ab04c;
+
+ const tile = this.add.rectangle(worldX, worldY, tileSize, tileSize, color);
+ tile.setOrigin(0);
+ tile.setAlpha(0.5);
+ }
+ }
+
+ // Audio trigger tile (YELLOW)
+ const triggerX = 10;
+ const triggerY = 7;
+ const triggerTile = this.add.rectangle(triggerX * tileSize, triggerY * tileSize, tileSize, tileSize, 0xffff00);
+ triggerTile.setOrigin(0);
+ triggerTile.setAlpha(0.7);
+
+ // Label
+ const label = this.add.text(triggerX * tileSize + 24, triggerY * tileSize + 24, '๐๏ธ', {
+ fontSize: '28px'
+ });
+ label.setOrigin(0.5);
+ }
+
+ createKai() {
+ // Simplified Kai representation
+ const kaiX = 240;
+ const kaiY = 240;
+
+ // Body
+ this.kai = this.add.circle(kaiX, kaiY, 20, 0xffc0cb); // Pink body
+ this.kai.setDepth(10);
+
+ // Add physics
+ this.physics.world.enable(this.kai);
+ this.kai.body.setCollideWorldBounds(true);
+ this.kai.speed = 150;
+
+ // Dreadlocks (will animate)
+ this.dreadlocks = [];
+ const dreadCount = 8;
+ const radius = 25;
+
+ for (let i = 0; i < dreadCount; i++) {
+ const angle = (i / dreadCount) * Math.PI * 2;
+ const x = kaiX + Math.cos(angle) * radius;
+ const y = kaiY + Math.sin(angle) * radius;
+
+ const dread = this.add.rectangle(x, y, 4, 30, 0xff1493); // Hot pink
+ dread.setOrigin(0.5, 0); // Pivot at top
+ dread.angle = (angle * 180 / Math.PI) + 90;
+ dread.setDepth(9);
+ dread.baseAngle = dread.angle;
+ dread.swayOffset = i * 0.5; // Stagger animation
+
+ this.dreadlocks.push(dread);
+ }
+
+ // Name label
+ this.kaiLabel = this.add.text(kaiX, kaiY - 40, 'KAI', {
+ fontSize: '14px',
+ fontFamily: 'Arial',
+ color: '#ffffff',
+ stroke: '#000000',
+ strokeThickness: 3
+ });
+ this.kaiLabel.setOrigin(0.5);
+ this.kaiLabel.setDepth(11);
+ }
+
+ initSystems() {
+ // Audio Trigger System
+ this.audioTriggers = new AudioTriggerSystem(this);
+
+ // Add trigger at yellow tile (10, 7)
+ this.audioTriggers.addTrigger(10, 7, 'kai_test_voice', {
+ radius: 0, // Exact tile only
+ volume: 1.0,
+ oneTime: true,
+ visualDebug: true,
+ callback: () => {
+ console.log('โ
Kai spoke her line!');
+ this.showSpeechBubble();
+ }
+ });
+
+ // Biome Music System (if music exists)
+ // this.biomeMusicSystem = new BiomeMusicSystem(this);
+ // this.biomeMusicSystem.playBiomeMusic('grassland');
+ }
+
+ showSpeechBubble() {
+ // Show speech bubble above Kai
+ const bubble = this.add.text(this.kai.x, this.kai.y - 60,
+ '"My name is Kai..."', {
+ fontSize: '12px',
+ fontFamily: 'Arial',
+ color: '#000000',
+ backgroundColor: '#ffffff',
+ padding: { x: 8, y: 4 }
+ });
+ bubble.setOrigin(0.5);
+ bubble.setDepth(100);
+
+ // Fade out after 3 seconds
+ this.tweens.add({
+ targets: bubble,
+ alpha: 0,
+ duration: 1000,
+ delay: 2000,
+ onComplete: () => bubble.destroy()
+ });
+ }
+
+ createFallingLeaves() {
+ // Particle emitter for falling leaves
+ this.leaves = [];
+
+ // Create 20 leaves
+ for (let i = 0; i < 20; i++) {
+ this.time.delayedCall(i * 300, () => {
+ this.spawnLeaf();
+ });
+ }
+ }
+
+ spawnLeaf() {
+ const x = Phaser.Math.Between(0, this.cameras.main.width);
+ const y = -20;
+
+ const leaf = this.add.ellipse(x, y, 8, 12, 0x90ee90);
+ leaf.setDepth(5);
+ leaf.setAlpha(0.7);
+
+ // Fall animation
+ this.tweens.add({
+ targets: leaf,
+ y: this.cameras.main.height + 50,
+ duration: Phaser.Math.Between(4000, 7000),
+ ease: 'Sine.InOut',
+ onComplete: () => {
+ leaf.destroy();
+ // Spawn new leaf
+ this.spawnLeaf();
+ }
+ });
+
+ // Sway side-to-side
+ this.tweens.add({
+ targets: leaf,
+ x: x + Phaser.Math.Between(-50, 50),
+ duration: 2000,
+ yoyo: true,
+ repeat: -1,
+ ease: 'Sine.InOut'
+ });
+
+ // Rotate
+ this.tweens.add({
+ targets: leaf,
+ angle: 360,
+ duration: 3000,
+ repeat: -1,
+ ease: 'Linear'
+ });
+ }
+
+ createWindEffect() {
+ // Dreadlocks sway in wind
+ this.time.addEvent({
+ delay: 50,
+ loop: true,
+ callback: () => {
+ const time = this.time.now / 1000;
+
+ this.dreadlocks.forEach((dread, i) => {
+ const sway = Math.sin(time * 2 + dread.swayOffset) * 15;
+ dread.angle = dread.baseAngle + sway;
+ });
+ }
+ });
+ }
+
+ update() {
+ if (!this.kai) return;
+
+ // Movement
+ this.kai.body.setVelocity(0);
+
+ if (this.cursors.left.isDown || this.wasd.left.isDown) {
+ this.kai.body.setVelocityX(-this.kai.speed);
+ } else if (this.cursors.right.isDown || this.wasd.right.isDown) {
+ this.kai.body.setVelocityX(this.kai.speed);
+ }
+
+ if (this.cursors.up.isDown || this.wasd.up.isDown) {
+ this.kai.body.setVelocityY(-this.kai.speed);
+ } else if (this.cursors.down.isDown || this.wasd.down.isDown) {
+ this.kai.body.setVelocityY(this.kai.speed);
+ }
+
+ // Update dreadlocks position
+ this.dreadlocks.forEach((dread, i) => {
+ const angle = (i / this.dreadlocks.length) * Math.PI * 2;
+ const radius = 25;
+ dread.x = this.kai.x + Math.cos(angle) * radius;
+ dread.y = this.kai.y + Math.sin(angle) * radius;
+ });
+
+ // Update label position
+ this.kaiLabel.setPosition(this.kai.x, this.kai.y - 40);
+
+ // Update audio triggers
+ this.audioTriggers.update(this.kai.x, this.kai.y);
+
+ // ESC to return to menu
+ if (this.input.keyboard.addKey('ESC').isDown) {
+ this.scene.start('GameScene');
+ }
+ }
+}
diff --git a/src/systems/AudioTriggerSystem.js b/src/systems/AudioTriggerSystem.js
new file mode 100644
index 000000000..6f8252611
--- /dev/null
+++ b/src/systems/AudioTriggerSystem.js
@@ -0,0 +1,181 @@
+/**
+ * AudioTriggerSystem.js
+ * Spatial audio triggers - play sound once when player enters area
+ */
+
+class AudioTriggerSystem {
+ constructor(scene) {
+ this.scene = scene;
+
+ // Active triggers
+ this.triggers = new Map();
+
+ // Triggered history (to prevent re-triggering)
+ this.triggered = new Set();
+
+ console.log('๐ AudioTriggerSystem initialized');
+ }
+
+ /**
+ * Add a spatial audio trigger
+ * @param {number} x - Grid X position
+ * @param {number} y - Grid Y position
+ * @param {string} audioKey - Audio file key
+ * @param {object} options - Additional options
+ */
+ addTrigger(x, y, audioKey, options = {}) {
+ const triggerId = `${x},${y}`;
+
+ const trigger = {
+ x,
+ y,
+ audioKey,
+ radius: options.radius || 0, // 0 = exact tile only
+ volume: options.volume || 1.0,
+ oneTime: options.oneTime !== false, // Default true
+ delay: options.delay || 0, // Delay before playing (ms)
+ callback: options.callback || null, // Optional callback after playing
+ visualDebug: options.visualDebug || false
+ };
+
+ this.triggers.set(triggerId, trigger);
+
+ // Add visual debug marker if enabled
+ if (trigger.visualDebug && this.scene.add) {
+ const worldX = x * 48 + 24;
+ const worldY = y * 48 + 24;
+
+ const circle = this.scene.add.circle(worldX, worldY, 20, 0x00ff00, 0.3);
+ circle.setDepth(1000);
+
+ const text = this.scene.add.text(worldX, worldY - 30, '๐', {
+ fontSize: '20px',
+ color: '#00ff00'
+ });
+ text.setOrigin(0.5);
+ text.setDepth(1001);
+ }
+
+ console.log(`โ
Audio trigger added at (${x}, ${y}): ${audioKey}`);
+
+ return triggerId;
+ }
+
+ /**
+ * Remove a trigger
+ */
+ removeTrigger(triggerId) {
+ this.triggers.delete(triggerId);
+ this.triggered.delete(triggerId);
+ }
+
+ /**
+ * Reset trigger (allow re-triggering)
+ */
+ resetTrigger(triggerId) {
+ this.triggered.delete(triggerId);
+ }
+
+ /**
+ * Reset all triggers
+ */
+ resetAll() {
+ this.triggered.clear();
+ }
+
+ /**
+ * Check if player is in trigger zone
+ */
+ checkTrigger(playerX, playerY) {
+ const playerGridX = Math.floor(playerX / 48);
+ const playerGridY = Math.floor(playerY / 48);
+
+ this.triggers.forEach((trigger, triggerId) => {
+ // Skip if already triggered and it's one-time only
+ if (trigger.oneTime && this.triggered.has(triggerId)) {
+ return;
+ }
+
+ // Check distance
+ const dx = Math.abs(playerGridX - trigger.x);
+ const dy = Math.abs(playerGridY - trigger.y);
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance <= trigger.radius) {
+ // TRIGGER!
+ this.activateTrigger(trigger, triggerId);
+ }
+ });
+ }
+
+ /**
+ * Activate a trigger (play audio)
+ */
+ activateTrigger(trigger, triggerId) {
+ console.log(`๐ TRIGGER ACTIVATED: ${triggerId} (${trigger.audioKey})`);
+
+ // Mark as triggered
+ this.triggered.add(triggerId);
+
+ // Play audio after delay
+ if (trigger.delay > 0) {
+ this.scene.time.delayedCall(trigger.delay, () => {
+ this.playAudio(trigger);
+ });
+ } else {
+ this.playAudio(trigger);
+ }
+ }
+
+ /**
+ * Play audio for trigger
+ */
+ playAudio(trigger) {
+ // Check if audio exists
+ if (!this.scene.cache.audio.exists(trigger.audioKey)) {
+ console.warn(`โ ๏ธ Audio not found: ${trigger.audioKey}`);
+ return;
+ }
+
+ // Play sound
+ const sound = this.scene.sound.add(trigger.audioKey, {
+ volume: trigger.volume
+ });
+
+ sound.play();
+
+ // Visual feedback (optional)
+ if (trigger.visualDebug) {
+ const worldX = trigger.x * 48 + 24;
+ const worldY = trigger.y * 48 + 24;
+
+ const flash = this.scene.add.circle(worldX, worldY, 30, 0xffff00, 0.8);
+ flash.setDepth(1002);
+
+ this.scene.tweens.add({
+ targets: flash,
+ alpha: 0,
+ scale: 2,
+ duration: 500,
+ ease: 'Quad.Out',
+ onComplete: () => flash.destroy()
+ });
+ }
+
+ // Callback
+ if (trigger.callback) {
+ trigger.callback();
+ }
+
+ console.log(`โ
Audio played: ${trigger.audioKey}`);
+ }
+
+ /**
+ * Update - called every frame
+ */
+ update(playerX, playerY) {
+ if (this.triggers.size === 0) return;
+
+ this.checkTrigger(playerX, playerY);
+ }
+}
diff --git a/src/systems/BiomeMusicSystem.js b/src/systems/BiomeMusicSystem.js
new file mode 100644
index 000000000..dacbe0695
--- /dev/null
+++ b/src/systems/BiomeMusicSystem.js
@@ -0,0 +1,171 @@
+/**
+ * BiomeMusicSystem.js
+ * Cross-fade background music based on biome transitions
+ */
+
+class BiomeMusicSystem {
+ constructor(scene) {
+ this.scene = scene;
+
+ // Music tracks by biome
+ this.biomeTracks = {
+ 'grassland': 'music/farm_ambient',
+ 'forest': 'music/forest_ambient',
+ 'town': 'music/town_theme',
+ 'combat': 'music/combat_theme',
+ 'night': 'music/night_theme'
+ };
+
+ // Current playing track
+ this.currentTrack = null;
+ this.currentBiome = null;
+
+ // Cross-fade settings
+ this.fadeDuration = 2000; // 2 seconds
+ this.volume = 0.5; // Master volume
+
+ console.log('๐ต BiomeMusicSystem initialized');
+ }
+
+ /**
+ * Preload all music tracks
+ */
+ preload() {
+ Object.entries(this.biomeTracks).forEach(([biome, track]) => {
+ if (this.scene.cache.audio.exists(track)) {
+ console.log(`โ
Music ready: ${track}`);
+ } else {
+ console.warn(`โ ๏ธ Music missing: ${track}`);
+ }
+ });
+ }
+
+ /**
+ * Start music for a biome
+ */
+ playBiomeMusic(biome) {
+ // Skip if already playing this biome's music
+ if (biome === this.currentBiome && this.currentTrack) {
+ return;
+ }
+
+ const trackKey = this.biomeTracks[biome];
+
+ if (!trackKey) {
+ console.warn(`โ ๏ธ No music for biome: ${biome}`);
+ return;
+ }
+
+ // Check if track exists
+ if (!this.scene.cache.audio.exists(trackKey)) {
+ console.warn(`โ ๏ธ Music not loaded: ${trackKey}`);
+ return;
+ }
+
+ console.log(`๐ต Transitioning to: ${biome} (${trackKey})`);
+
+ // Cross-fade to new track
+ this.crossFadeTo(trackKey, biome);
+ }
+
+ /**
+ * Cross-fade from current track to new track
+ */
+ crossFadeTo(newTrackKey, biome) {
+ const oldTrack = this.currentTrack;
+
+ // Create new track
+ const newTrack = this.scene.sound.add(newTrackKey, {
+ loop: true,
+ volume: 0 // Start silent
+ });
+
+ newTrack.play();
+
+ // Fade in new track
+ this.scene.tweens.add({
+ targets: newTrack,
+ volume: this.volume,
+ duration: this.fadeDuration,
+ ease: 'Linear'
+ });
+
+ // Fade out old track if it exists
+ if (oldTrack) {
+ this.scene.tweens.add({
+ targets: oldTrack,
+ volume: 0,
+ duration: this.fadeDuration,
+ ease: 'Linear',
+ onComplete: () => {
+ oldTrack.stop();
+ oldTrack.destroy();
+ }
+ });
+ }
+
+ // Update current track
+ this.currentTrack = newTrack;
+ this.currentBiome = biome;
+ }
+
+ /**
+ * Stop all music
+ */
+ stop() {
+ if (this.currentTrack) {
+ this.scene.tweens.add({
+ targets: this.currentTrack,
+ volume: 0,
+ duration: 1000,
+ ease: 'Linear',
+ onComplete: () => {
+ this.currentTrack.stop();
+ this.currentTrack.destroy();
+ this.currentTrack = null;
+ this.currentBiome = null;
+ }
+ });
+ }
+ }
+
+ /**
+ * Set master volume
+ */
+ setVolume(volume) {
+ this.volume = Phaser.Math.Clamp(volume, 0, 1);
+
+ if (this.currentTrack) {
+ this.currentTrack.setVolume(this.volume);
+ }
+ }
+
+ /**
+ * Update called every frame
+ * Checks player's current biome and switches music
+ */
+ update(playerX, playerY) {
+ // Get current biome from biomeSystem
+ if (!this.scene.biomeSystem) return;
+
+ const gridX = Math.floor(playerX / 48);
+ const gridY = Math.floor(playerY / 48);
+
+ const biome = this.scene.biomeSystem.getBiomeAt(gridX, gridY);
+
+ if (biome && biome !== this.currentBiome) {
+ this.playBiomeMusic(biome);
+ }
+
+ // Handle night music override
+ if (this.scene.timeSystem) {
+ const hour = this.scene.timeSystem.currentHour || 12;
+
+ if (hour >= 20 || hour < 6) { // Night time
+ if (this.currentBiome !== 'night') {
+ this.playBiomeMusic('night');
+ }
+ }
+ }
+ }
+}