Class_generator/src/games/RiverRun.js
StillHammer e4d7e838d5 Enhance game modules with visual effects and improvements
Add visual enhancements including fireball animations, improved rendering, and physics updates across multiple game modules (WizardSpellCaster, MarioEducational, WordDiscovery, WordStorm, RiverRun, GrammarDiscovery).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 14:09:13 +08:00

1879 lines
60 KiB
JavaScript

import Module from '../core/Module.js';
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
class RiverRun extends Module {
constructor(name, dependencies, config = {}) {
super(name || 'river-run', ['eventBus']);
if (!dependencies.eventBus || !dependencies.content) {
throw new Error('RiverRun requires eventBus and content dependencies');
}
// Ensure name is always defined (fallback to gameId)
if (!this.name) {
this.name = 'river-run';
}
this._eventBus = dependencies.eventBus;
this._content = dependencies.content;
this._config = {
container: null,
difficulty: 'medium',
initialSpeed: 2,
initialLives: 3,
spawnInterval: 1000,
...config
};
this._isRunning = false;
this._score = 0;
this._lives = this._config.initialLives;
this._level = 1;
this._speed = this._config.initialSpeed;
this._wordsCollected = 0;
this._player = {
x: 50,
y: 80,
targetX: 50,
targetY: 80,
size: 40
};
this._floatingWords = [];
this._currentTarget = null;
this._targetQueue = [];
this._powerUps = [];
this._particles = [];
this._availableWords = [];
this._usedTargets = [];
this._riverOffset = 0;
this._lastSpawn = 0;
this._gameStartTime = 0;
this._wordsSpawnedSinceTarget = 0;
this._maxWordsBeforeTarget = 10;
this._gameContainer = null;
this._animationFrame = null;
// Background music
this._audioContext = null;
this._backgroundMusicNodes = [];
this._isMusicPlaying = false;
this._musicLoopTimeout = null;
// Power-up state
this._slowTimeActive = false;
this._slowTimeTimeout = null;
Object.seal(this);
}
static getMetadata() {
return {
id: 'river-run',
name: 'River Run',
description: 'Navigate down a river collecting target vocabulary words while avoiding obstacles',
version: '2.0.0',
author: 'Class Generator',
category: 'action',
tags: ['vocabulary', 'action', 'reflex', 'collection'],
difficulty: {
min: 1,
max: 4,
default: 2
},
estimatedDuration: 8,
requiredContent: ['vocabulary']
};
}
static getCompatibilityScore(content) {
if (!content || !content.vocabulary) {
return 0;
}
let score = 50;
if (typeof content.vocabulary === 'object') {
const vocabCount = Object.keys(content.vocabulary).length;
if (vocabCount >= 10) score += 25;
if (vocabCount >= 20) score += 15;
if (vocabCount >= 30) score += 10;
} else if (content.letters) {
score += 20;
}
return Math.min(score, 100);
}
async init() {
this._validateNotDestroyed();
// Validate container
if (!this._config.container) {
throw new Error('Game container is required');
}
this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name);
this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name);
this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name);
soundSystem.initialize();
this._injectCSS();
// Start game immediately
try {
this._gameContainer = this._config.container;
const content = this._content;
if (!content) {
throw new Error('No content available');
}
this._extractContent(content);
if (this._availableWords.length === 0) {
throw new Error('No vocabulary found for River Run');
}
this._generateTargetQueue();
this._createGameBoard();
this._setupEventListeners();
this._updateHUD();
// Emit game ready event
this._eventBus.emit('game:ready', {
gameId: 'river-run',
instanceId: this.name,
vocabulary: this._availableWords.length
}, this.name);
} catch (error) {
console.error('Error starting River Run:', error);
this._showInitError(error.message);
}
this._setInitialized();
}
async destroy() {
this._validateNotDestroyed();
this._cleanup();
this._removeCSS();
this._eventBus.off('game:start', this.name);
this._eventBus.off('game:stop', this.name);
this._eventBus.off('navigation:change', this.name);
this._setDestroyed();
}
_handleGameStart(event) {
this._validateInitialized();
if (event.gameId === 'river-run') {
this._startGame();
}
}
_handleGameStop(event) {
this._validateInitialized();
if (event.gameId === 'river-run') {
this._stopGame();
}
}
_handleNavigationChange(event) {
this._validateInitialized();
if (event.from === '/games/river-run') {
this._cleanup();
}
}
async _startGame() {
try {
this._gameContainer = document.getElementById('game-content');
if (!this._gameContainer) {
throw new Error('Game container not found');
}
const content = await this._content.getCurrentContent();
if (!content) {
throw new Error('No content available');
}
this._extractContent(content);
if (this._availableWords.length === 0) {
throw new Error('No vocabulary found for River Run');
}
this._generateTargetQueue();
this._createGameBoard();
this._setupEventListeners();
this._updateHUD();
} catch (error) {
console.error('Error starting River Run:', error);
this._showInitError(error.message);
}
}
_stopGame() {
this._cleanup();
}
_cleanup() {
this._isRunning = false;
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
this._animationFrame = null;
}
// Clear slow time timeout if active
if (this._slowTimeTimeout) {
clearTimeout(this._slowTimeTimeout);
this._slowTimeTimeout = null;
}
this._slowTimeActive = false;
this._stopBackgroundMusic();
if (this._gameContainer) {
this._gameContainer.innerHTML = '';
}
if (window.currentRiverGame === this) {
delete window.currentRiverGame;
}
}
_showInitError(message) {
this._gameContainer.innerHTML = `
<div class="game-error">
<h3>❌ Loading Error</h3>
<p>${message}</p>
<p>The game requires vocabulary content with words and translations.</p>
<button onclick="window.app.getCore().router.navigate('/games')" class="back-btn">← Back to Games</button>
</div>
`;
}
_extractContent(content) {
this._availableWords = [];
if (content.vocabulary) {
Object.keys(content.vocabulary).forEach(word => {
const wordData = content.vocabulary[word];
this._availableWords.push({
french: word,
english: typeof wordData === 'string' ? wordData :
wordData.translation || wordData.user_language || 'unknown',
pronunciation: wordData.pronunciation || wordData.prononciation
});
});
}
if (content.letters && this._availableWords.length === 0) {
Object.values(content.letters).forEach(letterWords => {
letterWords.forEach(wordData => {
this._availableWords.push({
french: wordData.word,
english: wordData.translation,
pronunciation: wordData.pronunciation
});
});
});
}
console.log(`River Run: ${this._availableWords.length} words loaded`);
}
_generateTargetQueue() {
this._targetQueue = this._shuffleArray([...this._availableWords]).slice(0, Math.min(10, this._availableWords.length));
this._usedTargets = [];
}
_createGameBoard() {
this._gameContainer.innerHTML = `
<div class="river-run-wrapper" id="river-game">
<div class="river-run-hud">
<div class="hud-left">
<div>Score: <span id="score-display">${this._score}</span></div>
<div>Lives: <span id="lives-display">${this._lives}</span></div>
<div>Words: <span id="words-display">${this._wordsCollected}</span></div>
</div>
<div class="target-display" id="target-display">
Click to Start!
</div>
<div class="hud-right">
<div>Level: <span id="level-display">${this._level}</span></div>
<div>Speed: <span id="speed-display">${this._speed.toFixed(1)}x</span></div>
</div>
</div>
<div class="river-canvas" id="river-canvas">
<div class="river-waves"></div>
<div class="player" id="player"></div>
</div>
</div>
`;
window.currentRiverGame = this;
}
_setupEventListeners() {
const riverGame = document.getElementById('river-game');
riverGame.addEventListener('click', (e) => {
if (!this._isRunning) {
this._start();
return;
}
const rect = riverGame.getBoundingClientRect();
const clickX = ((e.clientX - rect.left) / rect.width) * 100;
const clickY = ((e.clientY - rect.top) / rect.height) * 100;
this._movePlayer(clickX, clickY);
});
riverGame.addEventListener('click', (e) => {
if (e.target.classList.contains('floating-word')) {
e.stopPropagation();
this._handleWordClick(e.target);
}
});
}
_start() {
if (this._isRunning) return;
this._isRunning = true;
this._gameStartTime = Date.now();
this._setNextTarget();
this._startBackgroundMusic();
this._gameLoop();
console.log('River Run started!');
}
_gameLoop() {
if (!this._isRunning) return;
const now = Date.now();
if (now - this._lastSpawn > this._config.spawnInterval) {
// Spawn multiple words based on speed: sqrt(speed) words per spawn cycle
// The decimal part is treated as probability for an additional word
const speedSqrt = Math.sqrt(this._speed);
const baseWords = Math.floor(speedSqrt);
const probability = speedSqrt - baseWords; // Decimal part (e.g., 2.7 -> 0.7)
// Always spawn at least the base number of words
let wordsToSpawn = Math.max(1, baseWords);
// Add one more word based on probability
if (Math.random() < probability) {
wordsToSpawn++;
}
for (let i = 0; i < wordsToSpawn; i++) {
// Add slight delay between each word spawn for visual variety
setTimeout(() => {
if (this._isRunning) {
this._spawnFloatingWord();
}
}, i * 100); // 100ms delay between each word
}
this._lastSpawn = now;
}
this._updateFloatingWords();
this._updatePlayer();
this._updateParticles();
this._checkCollisions();
this._updateDifficulty();
this._updateHUD();
this._animationFrame = requestAnimationFrame(() => this._gameLoop());
}
_setNextTarget() {
if (this._targetQueue.length === 0) {
this._generateTargetQueue();
}
this._currentTarget = this._targetQueue.shift();
this._usedTargets.push(this._currentTarget);
this._wordsSpawnedSinceTarget = 0;
const targetDisplay = document.getElementById('target-display');
if (targetDisplay) {
targetDisplay.innerHTML = `Find: <span style="color: #FF6B35;">${this._currentTarget.english}</span>`;
}
}
_spawnFloatingWord() {
const riverCanvas = document.getElementById('river-canvas');
if (!riverCanvas) return;
let word;
if (this._wordsSpawnedSinceTarget >= this._maxWordsBeforeTarget) {
word = this._currentTarget;
this._wordsSpawnedSinceTarget = 0;
} else {
word = this._getRandomWord();
this._wordsSpawnedSinceTarget++;
}
const wordElement = document.createElement('div');
wordElement.className = 'floating-word';
const spacePadding = ' '.repeat(this._level * 2);
wordElement.textContent = spacePadding + word.french + spacePadding;
// More random positioning with different strategies
let xPosition;
const strategy = Math.random();
if (strategy < 0.4) {
// Random across full width (with margins)
xPosition = Math.random() * 80 + 10;
} else if (strategy < 0.6) {
// Prefer left side
xPosition = Math.random() * 40 + 10;
} else if (strategy < 0.8) {
// Prefer right side
xPosition = Math.random() * 40 + 50;
} else {
// Prefer center
xPosition = Math.random() * 30 + 35;
}
// Add slight random variation to starting Y position for staggered effect
const yStart = -60 - Math.random() * 40;
wordElement.style.left = `${xPosition}%`;
wordElement.style.top = `${yStart}px`;
wordElement.wordData = word;
riverCanvas.appendChild(wordElement);
this._floatingWords.push({
element: wordElement,
y: yStart,
x: xPosition,
wordData: word
});
if (Math.random() < 0.1) {
this._spawnPowerUp();
}
}
_getRandomWord() {
return this._availableWords[Math.floor(Math.random() * this._availableWords.length)];
}
_spawnPowerUp() {
const riverCanvas = document.getElementById('river-canvas');
if (!riverCanvas) return;
const powerUpElement = document.createElement('div');
powerUpElement.className = 'power-up';
powerUpElement.innerHTML = '⚡';
// Random positioning similar to words
const xPosition = Math.random() * 80 + 10;
const yStart = -40 - Math.random() * 30;
powerUpElement.style.left = `${xPosition}%`;
powerUpElement.style.top = `${yStart}px`;
riverCanvas.appendChild(powerUpElement);
this._powerUps.push({
element: powerUpElement,
y: yStart,
x: xPosition,
type: 'slowTime'
});
}
_updateFloatingWords() {
const riverCanvas = document.getElementById('river-canvas');
const canvasHeight = riverCanvas ? riverCanvas.offsetHeight : window.innerHeight;
this._floatingWords = this._floatingWords.filter(word => {
word.y += this._speed;
word.element.style.top = `${word.y}px`;
// Check if word has gone below the visible game area
if (word.y > canvasHeight - 50) {
// Note: No longer losing life when target word escapes
// Life loss only happens on collision with wrong words
word.element.remove();
return false;
}
return true;
});
this._powerUps = this._powerUps.filter(powerUp => {
powerUp.y += this._speed;
powerUp.element.style.top = `${powerUp.y}px`;
// Check if power-up has gone below the visible game area
if (powerUp.y > canvasHeight - 50) {
powerUp.element.remove();
return false;
}
return true;
});
}
_movePlayer(targetX, targetY) {
this._player.targetX = Math.max(5, Math.min(95, targetX));
this._player.targetY = Math.max(10, Math.min(90, targetY));
const playerElement = document.getElementById('player');
if (playerElement) {
playerElement.classList.add('moving');
setTimeout(() => {
playerElement.classList.remove('moving');
}, 500);
}
this._createRippleEffect(targetX, targetY);
}
_updatePlayer() {
const speed = 0.1;
this._player.x += (this._player.targetX - this._player.x) * speed;
this._player.y += (this._player.targetY - this._player.y) * speed;
const playerElement = document.getElementById('player');
if (playerElement) {
playerElement.style.left = `calc(${this._player.x}% - 20px)`;
playerElement.style.top = `calc(${this._player.y}% - 20px)`;
}
}
_createRippleEffect(x, y) {
for (let i = 0; i < 5; i++) {
setTimeout(() => {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = `${x}%`;
particle.style.top = `${y}%`;
particle.style.animation = `particleSpread 1s ease-out forwards`;
const riverCanvas = document.getElementById('river-canvas');
if (riverCanvas) {
riverCanvas.appendChild(particle);
setTimeout(() => {
particle.remove();
}, 1000);
}
}, i * 100);
}
}
_updateParticles() {
if (Math.random() < 0.1) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = `${Math.random() * 100}%`;
particle.style.top = '-5px';
particle.style.animation = `particleFlow 3s linear forwards`;
const riverCanvas = document.getElementById('river-canvas');
if (riverCanvas) {
riverCanvas.appendChild(particle);
setTimeout(() => {
particle.remove();
}, 3000);
}
}
}
_checkCollisions() {
const playerRect = this._getPlayerRect();
// Check word collisions (iterate backwards to safely splice)
for (let i = this._floatingWords.length - 1; i >= 0; i--) {
const word = this._floatingWords[i];
// Skip if word was already collected
if (word.element.dataset.collected === 'true') continue;
const wordRect = this._getElementRect(word.element);
if (this._isColliding(playerRect, wordRect)) {
this._handleWordCollision(word, i);
}
}
// Check power-up collisions (iterate backwards to safely splice)
for (let i = this._powerUps.length - 1; i >= 0; i--) {
const powerUp = this._powerUps[i];
const powerUpRect = this._getElementRect(powerUp.element);
if (this._isColliding(playerRect, powerUpRect)) {
this._handlePowerUpCollision(powerUp, i);
}
}
}
_getPlayerRect() {
const playerElement = document.getElementById('player');
if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 };
const canvas = document.getElementById('river-canvas');
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
const rect = playerElement.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
return {
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height
};
}
_getElementRect(element) {
if (!element) return { x: 0, y: 0, width: 0, height: 0 };
const canvas = document.getElementById('river-canvas');
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
const rect = element.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
return {
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height
};
}
_isColliding(rect1, rect2) {
return rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y;
}
_handleWordClick(wordElement) {
const wordData = wordElement.wordData;
if (wordData.french === this._currentTarget.french) {
this._collectWord(wordElement, true);
} else {
this._missWord(wordElement);
}
}
_handleWordCollision(word, index) {
// Handle collision for ALL words:
// - TARGET word: auto-collect (points)
// - WRONG word: lose life
if (word.wordData && this._currentTarget) {
if (word.wordData.french === this._currentTarget.french) {
// Correct word - collect it
this._collectWord(word.element, true);
} else {
// Wrong word - lose life
this._missWord(word.element);
}
// Remove the word from the array to prevent multiple collisions
this._floatingWords.splice(index, 1);
// Also mark as collected to prevent further processing
word.element.dataset.collected = 'true';
}
}
_collectWord(wordElement, isCorrect) {
wordElement.classList.add('collected');
if (isCorrect) {
soundSystem.play('coin');
// Base points increased with level, multiplied by sqrt of speed
const basePoints = 10 + (this._level * 2);
const speedMultiplier = Math.sqrt(this._speed);
const pointsEarned = Math.round(basePoints * speedMultiplier);
this._score += pointsEarned;
this._wordsCollected++;
// Show points earned (visual feedback)
this._showPointsPopup(wordElement, pointsEarned);
this._eventBus.emit('game:score-update', {
gameId: 'river-run',
score: this._score,
module: this.name
}, this.name);
this._setNextTarget();
this._playSuccessSound(wordElement.textContent);
}
setTimeout(() => {
wordElement.remove();
}, 800);
}
_missWord(wordElement) {
soundSystem.play('enemy_defeat');
wordElement.classList.add('missed');
// Add shaking animation to the wrong word
wordElement.style.animation = 'wordShake 0.4s ease-in-out, wordMissed 0.6s ease-out forwards';
// Shake the entire screen
this._shakeScreen();
this._loseLife();
setTimeout(() => {
wordElement.remove();
}, 600);
}
_shakeScreen() {
const riverGame = document.getElementById('river-game');
if (!riverGame) return;
riverGame.classList.add('screen-shake');
setTimeout(() => {
riverGame.classList.remove('screen-shake');
}, 500);
}
_handlePowerUpCollision(powerUp, index) {
this._activatePowerUp(powerUp.type);
powerUp.element.remove();
this._powerUps.splice(index, 1);
}
_activatePowerUp(type) {
switch (type) {
case 'slowTime':
// Clear any existing slowTime effect
if (this._slowTimeTimeout) {
clearTimeout(this._slowTimeTimeout);
}
// Activate slow time effect
this._slowTimeActive = true;
soundSystem.play('powerup');
// Visual feedback - add a slow-time indicator to HUD
this._showSlowTimeIndicator();
// Reset after 3 seconds
this._slowTimeTimeout = setTimeout(() => {
this._slowTimeActive = false;
this._slowTimeTimeout = null;
this._hideSlowTimeIndicator();
}, 3000);
break;
}
}
_updateDifficulty() {
const timeElapsed = Date.now() - this._gameStartTime;
const secondsElapsed = timeElapsed / 1000;
// Progressive speed increase: starts at initialSpeed, increases gradually
// Speed increases by 0.1 every 5 seconds (0.02 per second)
const speedIncrease = secondsElapsed * 0.02;
const baseSpeed = this._config.initialSpeed + speedIncrease;
// Apply slow-time multiplier if power-up is active
this._speed = this._slowTimeActive ? baseSpeed * 0.5 : baseSpeed;
// Update level every 30 seconds
const newLevel = Math.floor(timeElapsed / 30000) + 1;
if (newLevel > this._level) {
this._level = newLevel;
// Decrease spawn interval with each level (spawn words more frequently)
this._config.spawnInterval = Math.max(500, 1000 - (this._level - 1) * 100);
}
}
_showSlowTimeIndicator() {
const hud = document.querySelector('.river-run-hud .hud-right');
if (!hud) return;
// Remove any existing indicator
const existing = document.getElementById('slowtime-indicator');
if (existing) existing.remove();
const indicator = document.createElement('div');
indicator.id = 'slowtime-indicator';
indicator.innerHTML = '⏱️ SLOW TIME';
indicator.style.cssText = `
background: linear-gradient(45deg, #FF6B35, #F7931E);
color: white;
padding: 5px 15px;
border-radius: 15px;
font-size: 0.9em;
font-weight: bold;
animation: slowTimePulse 0.5s ease-in-out infinite alternate;
box-shadow: 0 0 15px rgba(255,107,53,0.8);
`;
hud.appendChild(indicator);
}
_hideSlowTimeIndicator() {
const indicator = document.getElementById('slowtime-indicator');
if (indicator) {
indicator.remove();
}
}
_showPointsPopup(wordElement, points) {
const rect = wordElement.getBoundingClientRect();
const riverCanvas = document.getElementById('river-canvas');
if (!riverCanvas) return;
const canvasRect = riverCanvas.getBoundingClientRect();
const popup = document.createElement('div');
popup.className = 'points-popup';
popup.textContent = `+${points}`;
popup.style.left = `${rect.left - canvasRect.left + rect.width / 2}px`;
popup.style.top = `${rect.top - canvasRect.top}px`;
riverCanvas.appendChild(popup);
setTimeout(() => {
popup.remove();
}, 1000);
}
async _playSuccessSound(word) {
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word.trim());
// Get language from content, fallback to zh-CN (Chinese) for vocabulary
const contentLanguage = this._content?.language || 'zh-CN';
utterance.lang = contentLanguage;
utterance.rate = 0.8;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// Wait for voices to be loaded and select the best one
const voices = await this._getVoices();
if (voices.length > 0) {
const langPrefix = contentLanguage.split('-')[0];
const matchingVoice = voices.find(voice =>
voice.lang === contentLanguage
) || voices.find(voice =>
voice.lang.startsWith(langPrefix)
);
if (matchingVoice) {
utterance.voice = matchingVoice;
console.log(`🔊 RiverRun using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
} else {
console.warn(`🔊 No voice found for: ${contentLanguage}`);
}
}
speechSynthesis.speak(utterance);
}
}
_getVoices() {
return new Promise((resolve) => {
let voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
resolve(voices);
return;
}
const voicesChangedHandler = () => {
voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
resolve(voices);
}
};
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
setTimeout(() => {
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
resolve(window.speechSynthesis.getVoices());
}, 1000);
});
}
_loseLife() {
this._lives--;
if (this._lives <= 0) {
this._gameOver();
}
}
_gameOver() {
this._isRunning = false;
this._stopBackgroundMusic();
const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0;
// Handle localStorage best score
const currentScore = this._score;
const bestScore = parseInt(localStorage.getItem('river-run-best-score') || '0');
const isNewBest = currentScore > bestScore;
if (isNewBest) {
localStorage.setItem('river-run-best-score', currentScore.toString());
}
// Emit game end event BEFORE showing popup
this._endGame();
this._showVictoryPopup({
gameTitle: 'River Run',
currentScore,
bestScore: isNewBest ? currentScore : bestScore,
isNewBest,
stats: {
'Words Collected': this._wordsCollected,
'Level Reached': this._level,
'Accuracy': `${accuracy}%`,
'Lives Remaining': this._lives
}
});
}
_endGame() {
// Use gameId instead of this.name which might be undefined
this._eventBus.emit('game:end', {
gameId: 'river-run',
score: this._score,
module: this.name || 'river-run'
}, this.name || 'river-run');
}
_updateHUD() {
const scoreDisplay = document.getElementById('score-display');
const livesDisplay = document.getElementById('lives-display');
const wordsDisplay = document.getElementById('words-display');
const levelDisplay = document.getElementById('level-display');
const speedDisplay = document.getElementById('speed-display');
if (scoreDisplay) scoreDisplay.textContent = this._score;
if (livesDisplay) livesDisplay.textContent = this._lives;
if (wordsDisplay) wordsDisplay.textContent = this._wordsCollected;
if (levelDisplay) levelDisplay.textContent = this._level;
if (speedDisplay) speedDisplay.textContent = this._speed.toFixed(1) + 'x';
}
_restart() {
// Stop any playing music before restarting
this._stopBackgroundMusic();
this._isRunning = false;
this._score = 0;
this._lives = this._config.initialLives;
this._level = 1;
this._speed = this._config.initialSpeed;
this._wordsCollected = 0;
this._riverOffset = 0;
this._player.x = 50;
this._player.y = 80;
this._player.targetX = 50;
this._player.targetY = 80;
this._floatingWords = [];
this._powerUps = [];
this._particles = [];
this._lastSpawn = 0;
this._config.spawnInterval = 1000;
this._gameStartTime = Date.now();
this._wordsSpawnedSinceTarget = 0;
this._generateTargetQueue();
const riverCanvas = document.getElementById('river-canvas');
if (riverCanvas) {
const words = riverCanvas.querySelectorAll('.floating-word');
const powerUps = riverCanvas.querySelectorAll('.power-up');
const particles = riverCanvas.querySelectorAll('.particle');
words.forEach(word => word.remove());
powerUps.forEach(powerUp => powerUp.remove());
particles.forEach(particle => particle.remove());
}
const gameOverModal = document.querySelector('.game-over-modal');
if (gameOverModal) {
gameOverModal.remove();
}
const targetDisplay = document.getElementById('target-display');
if (targetDisplay) {
targetDisplay.textContent = 'Click to Start!';
}
this._updateHUD();
console.log('River Run restarted');
}
_startBackgroundMusic() {
if (this._isMusicPlaying) return;
try {
// Create audio context
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create master gain for volume control (quiet background music)
const masterGain = this._audioContext.createGain();
masterGain.gain.value = 0.15; // Very quiet, 15% volume
masterGain.connect(this._audioContext.destination);
// River-like pentatonic scale (C D E G A) - peaceful and flowing
const frequencies = [261.63, 293.66, 329.63, 392.00, 440.00]; // C4, D4, E4, G4, A4
// Create multiple oscillators for a richer sound
const createNote = (freq, startTime, duration, gainValue) => {
const oscillator = this._audioContext.createOscillator();
const gainNode = this._audioContext.createGain();
oscillator.type = 'sine'; // Soft sine wave
oscillator.frequency.setValueAtTime(freq, this._audioContext.currentTime);
// Envelope: fade in and fade out
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(gainValue, startTime + 0.1);
gainNode.gain.linearRampToValueAtTime(gainValue * 0.7, startTime + duration - 0.3);
gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
oscillator.connect(gainNode);
gainNode.connect(masterGain);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
return { oscillator, gainNode };
};
// Create a flowing melodic pattern
const playMelody = () => {
if (!this._isMusicPlaying || !this._audioContext) return;
const now = this._audioContext.currentTime;
const noteDuration = 1.5; // Longer notes for a relaxed feel
// Play a sequence of notes with random variation (like water flowing)
for (let i = 0; i < 4; i++) {
const randomIndex = Math.floor(Math.random() * frequencies.length);
const freq = frequencies[randomIndex];
const startTime = now + (i * noteDuration);
const gainValue = 0.3 + Math.random() * 0.2; // Vary volume slightly
createNote(freq, startTime, noteDuration * 1.2, gainValue);
}
// Schedule next melody and store timeout ID
this._musicLoopTimeout = setTimeout(() => playMelody(), noteDuration * 4 * 1000);
};
// Add subtle low drone (like distant river sound)
const bassDrone = this._audioContext.createOscillator();
const bassGain = this._audioContext.createGain();
bassDrone.type = 'sine';
bassDrone.frequency.value = 65.41; // C2 - very low
bassGain.gain.value = 0.08; // Very subtle
bassDrone.connect(bassGain);
bassGain.connect(masterGain);
bassDrone.start();
this._backgroundMusicNodes.push({ oscillator: bassDrone, gainNode: bassGain });
this._isMusicPlaying = true;
// Start the melody
playMelody();
console.log('🎵 River background music started');
} catch (error) {
console.warn('Failed to start background music:', error);
}
}
_stopBackgroundMusic() {
if (!this._isMusicPlaying) return;
try {
// Clear the melody loop timeout
if (this._musicLoopTimeout) {
clearTimeout(this._musicLoopTimeout);
this._musicLoopTimeout = null;
}
// Stop all oscillators
this._backgroundMusicNodes.forEach(node => {
if (node.oscillator) {
try {
node.oscillator.stop();
} catch (e) {
// Oscillator might already be stopped
}
}
});
// Close audio context
if (this._audioContext) {
this._audioContext.close();
this._audioContext = null;
}
this._backgroundMusicNodes = [];
this._isMusicPlaying = false;
console.log('🎵 River background music stopped');
} catch (error) {
console.warn('Failed to stop background music:', error);
}
}
_shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
_injectCSS() {
const cssId = 'river-run-styles';
if (document.getElementById(cssId)) return;
const style = document.createElement('style');
style.id = cssId;
style.textContent = `
.river-run-wrapper {
background: linear-gradient(180deg, #87CEEB 0%, #4682B4 50%, #2F4F4F 100%);
position: relative;
overflow: hidden;
height: 100vh;
cursor: crosshair;
}
.river-run-wrapper.screen-shake {
animation: screenShake 0.5s ease-in-out;
}
@keyframes screenShake {
0%, 100% {
transform: translate(0, 0);
}
10%, 30%, 50%, 70%, 90% {
transform: translate(-10px, 5px);
}
20%, 40%, 60%, 80% {
transform: translate(10px, -5px);
}
}
.river-run-hud {
position: absolute;
top: 20px;
left: 20px;
right: 20px;
display: flex;
justify-content: space-between;
z-index: 100;
color: white;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.hud-left, .hud-right {
display: flex;
gap: 20px;
align-items: center;
}
.target-display {
background: rgba(255,255,255,0.9);
color: #333;
padding: 10px 20px;
border-radius: 25px;
font-size: 1.2em;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
animation: targetGlow 2s ease-in-out infinite alternate;
}
@keyframes targetGlow {
from { box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
to { box-shadow: 0 4px 20px rgba(255,215,0,0.6); }
}
.river-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at center top, rgba(135,206,235,0.3) 0%, transparent 70%),
linear-gradient(0deg,
rgba(70,130,180,0.1) 0%,
rgba(135,206,235,0.05) 50%,
rgba(173,216,230,0.1) 100%
);
}
.river-waves {
position: absolute;
width: 120%;
height: 100%;
background:
repeating-linear-gradient(
0deg,
transparent 0px,
rgba(255,255,255,0.1) 2px,
transparent 4px,
transparent 20px
);
animation: riverFlow 3s linear infinite;
}
@keyframes riverFlow {
from { transform: translateY(-20px); }
to { transform: translateY(0px); }
}
.player {
position: absolute;
width: 40px;
height: 40px;
background: linear-gradient(45deg, #8B4513, #A0522D);
border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%;
box-shadow:
0 2px 10px rgba(0,0,0,0.3),
inset 0 2px 5px rgba(255,255,255,0.3);
transition: all 0.3s ease-out;
z-index: 50;
transform-origin: center;
}
.player::before {
content: '🛶';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));
}
.player.moving {
animation: playerRipple 0.5s ease-out;
}
@keyframes playerRipple {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.floating-word {
position: absolute;
background: rgba(255,255,255,0.95);
border: 3px solid #4682B4;
border-radius: 15px;
padding: 8px 15px;
font-size: 1.1em;
font-weight: bold;
color: #333;
cursor: pointer;
transition: all 0.2s ease;
z-index: 40;
box-shadow:
0 4px 15px rgba(0,0,0,0.2),
0 0 0 0 rgba(70,130,180,0.4);
animation: wordFloat 3s ease-in-out infinite alternate;
}
@keyframes wordFloat {
from { transform: translateY(0px) rotate(-1deg); }
to { transform: translateY(-5px) rotate(1deg); }
}
.floating-word:hover {
transform: scale(1.1) translateY(-3px);
box-shadow:
0 6px 20px rgba(0,0,0,0.3),
0 0 20px rgba(70,130,180,0.6);
}
.floating-word.collected {
animation: wordCollected 0.8s ease-out forwards;
}
@keyframes wordCollected {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3);
opacity: 0.8;
}
100% {
transform: scale(0) translateY(-50px);
opacity: 0;
}
}
.floating-word.missed {
animation: wordMissed 0.6s ease-out forwards;
}
@keyframes wordMissed {
0% {
transform: scale(1);
opacity: 1;
background: rgba(255,255,255,0.95);
}
100% {
transform: scale(0.8);
opacity: 0;
background: rgba(220,20,60,0.8);
}
}
@keyframes wordShake {
0%, 100% {
transform: translateX(0) scale(1);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-10px) scale(1.05);
}
20%, 40%, 60%, 80% {
transform: translateX(10px) scale(1.05);
}
}
.power-up {
position: absolute;
width: 35px;
height: 35px;
border-radius: 50%;
background: linear-gradient(45deg, #FF6B35, #F7931E);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
cursor: pointer;
z-index: 45;
animation: powerUpFloat 2s ease-in-out infinite alternate;
box-shadow: 0 4px 15px rgba(255,107,53,0.4);
}
@keyframes powerUpFloat {
from { transform: translateY(0px) scale(1); }
to { transform: translateY(-8px) scale(1.05); }
}
@keyframes slowTimePulse {
from {
transform: scale(1);
box-shadow: 0 0 15px rgba(255,107,53,0.8);
}
to {
transform: scale(1.05);
box-shadow: 0 0 25px rgba(255,107,53,1);
}
}
.game-over-modal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255,255,255,0.95);
padding: 40px;
border-radius: 20px;
text-align: center;
z-index: 200;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
backdrop-filter: blur(10px);
}
.game-over-title {
font-size: 2.5em;
margin-bottom: 20px;
color: #4682B4;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.game-over-stats {
font-size: 1.3em;
margin-bottom: 30px;
line-height: 1.6;
color: #333;
}
.river-btn {
background: linear-gradient(45deg, #4682B4, #5F9EA0);
color: white;
border: none;
padding: 15px 30px;
border-radius: 25px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
margin: 0 10px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(70,130,180,0.3);
}
.river-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(70,130,180,0.4);
}
.particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(255,255,255,0.7);
border-radius: 50%;
pointer-events: none;
z-index: 30;
}
.points-popup {
position: absolute;
color: #FFD700;
font-size: 1.5em;
font-weight: bold;
pointer-events: none;
z-index: 100;
text-shadow:
0 0 10px rgba(255,215,0,0.8),
0 2px 4px rgba(0,0,0,0.5);
animation: pointsFloat 1s ease-out forwards;
transform: translate(-50%, 0);
}
@keyframes pointsFloat {
0% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -30px) scale(1.2);
}
100% {
opacity: 0;
transform: translate(-50%, -60px) scale(0.8);
}
}
.game-error {
background: rgba(239, 68, 68, 0.1);
border: 2px solid #ef4444;
border-radius: 15px;
padding: 30px;
text-align: center;
color: #374151;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 500px;
}
.game-error h3 {
color: #ef4444;
margin-bottom: 15px;
}
.back-btn {
background: linear-gradient(135deg, #6b7280, #4b5563);
color: white;
border: none;
padding: 12px 25px;
border-radius: 25px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
margin-top: 20px;
transition: all 0.3s ease;
}
.back-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(107, 114, 128, 0.4);
}
@keyframes particleSpread {
0% {
transform: scale(1) translate(0, 0);
opacity: 1;
}
100% {
transform: scale(0) translate(50px, 50px);
opacity: 0;
}
}
@keyframes particleFlow {
0% {
transform: translateY(0);
opacity: 0.7;
}
100% {
transform: translateY(100vh);
opacity: 0;
}
}
@media (max-width: 768px) {
.river-run-hud {
flex-direction: column;
gap: 10px;
}
.floating-word {
font-size: 1em;
padding: 6px 12px;
}
.target-display {
font-size: 1em;
padding: 8px 15px;
}
.hud-left, .hud-right {
justify-content: center;
}
}
/* Victory Popup Styles */
.victory-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
}
.victory-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
padding: 40px;
text-align: center;
color: white;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.4s ease-out;
}
.victory-header {
margin-bottom: 30px;
}
.victory-icon {
font-size: 4rem;
margin-bottom: 15px;
animation: bounce 0.6s ease-out;
}
.victory-title {
font-size: 2rem;
font-weight: bold;
margin: 0 0 10px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.new-best-badge {
background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 8px 20px;
border-radius: 25px;
font-size: 0.9rem;
font-weight: bold;
display: inline-block;
margin-top: 10px;
animation: glow 1s ease-in-out infinite alternate;
}
.victory-scores {
display: flex;
justify-content: space-around;
margin: 30px 0;
gap: 20px;
}
.score-display {
background: rgba(255, 255, 255, 0.1);
border-radius: 15px;
padding: 20px;
flex: 1;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.score-label {
font-size: 0.9rem;
opacity: 0.9;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
.score-value {
font-size: 2rem;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.victory-stats {
background: rgba(255, 255, 255, 0.1);
border-radius: 15px;
padding: 20px;
margin: 30px 0;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-row:last-child {
border-bottom: none;
}
.stat-name {
font-size: 0.95rem;
opacity: 0.9;
}
.stat-value {
font-weight: bold;
font-size: 1rem;
}
.victory-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 30px;
}
.victory-btn {
padding: 15px 30px;
border: none;
border-radius: 25px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 1px;
}
.victory-btn.primary {
background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%);
color: white;
box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3);
}
.victory-btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4);
}
.victory-btn.secondary {
background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%);
color: #333;
box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3);
}
.victory-btn.secondary:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4);
}
.victory-btn.tertiary {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
.victory-btn.tertiary:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
@keyframes glow {
from {
box-shadow: 0 0 20px rgba(245, 87, 108, 0.5);
}
to {
box-shadow: 0 0 30px rgba(245, 87, 108, 0.8);
}
}
@media (max-width: 768px) {
.victory-content {
padding: 30px 20px;
width: 95%;
}
.victory-scores {
flex-direction: column;
gap: 15px;
}
.victory-icon {
font-size: 3rem;
}
.victory-title {
font-size: 1.5rem;
}
.victory-buttons {
gap: 10px;
}
.victory-btn {
padding: 12px 25px;
font-size: 0.9rem;
}
}
`;
document.head.appendChild(style);
}
_showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) {
const popup = document.createElement('div');
popup.className = 'victory-popup';
popup.innerHTML = `
<div class="victory-content">
<div class="victory-header">
<div class="victory-icon">🌊</div>
<h2 class="victory-title">${gameTitle} Complete!</h2>
${isNewBest ? '<div class="new-best-badge">🎉 New Best Score!</div>' : ''}
</div>
<div class="victory-scores">
<div class="score-display">
<div class="score-label">Your Score</div>
<div class="score-value">${currentScore}</div>
</div>
<div class="score-display best-score">
<div class="score-label">Best Score</div>
<div class="score-value">${bestScore}</div>
</div>
</div>
<div class="victory-stats">
${Object.entries(stats).map(([key, value]) => `
<div class="stat-row">
<span class="stat-name">${key}</span>
<span class="stat-value">${value}</span>
</div>
`).join('')}
</div>
<div class="victory-buttons">
<button class="victory-btn primary" onclick="this.closest('.victory-popup').remove(); window.currentRiverGame._restart();">🔄 Play Again</button>
<button class="victory-btn secondary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/games');">🎮 Different Game</button>
<button class="victory-btn tertiary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/');">🏠 Main Menu</button>
</div>
</div>
`;
document.body.appendChild(popup);
}
_removeCSS() {
const cssElement = document.getElementById('river-run-styles');
if (cssElement) {
cssElement.remove();
}
if (window.currentRiverGame === this) {
delete window.currentRiverGame;
}
}
}
export default RiverRun;