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 = `
❌ Loading Error
${message}
The game requires vocabulary content with words and translations.
`;
}
_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 = `
Score: ${this._score}
Lives: ${this._lives}
Words: ${this._wordsCollected}
Click to Start!
Level: ${this._level}
Speed: ${this._speed.toFixed(1)}x
`;
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: ${this._currentTarget.english}`;
}
}
_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 = `
Your Score
${currentScore}
${Object.entries(stats).map(([key, value]) => `
${key}
${value}
`).join('')}
`;
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;