- Render floating words as wooden trunks using Canvas instead of div elements
- Trunks scale proportionally to word length (longer words = bigger trunks)
- Add realistic wood texture with grain, rings, and highlights
- Display word text both on trunk and below for clarity
- Improve event handling for Canvas-based clickable elements
- Update styles and animations to work with Canvas elements
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
1895 lines
61 KiB
JavaScript
1895 lines
61 KiB
JavaScript
import Module from '../core/Module.js';
|
|
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
|
|
import ttsService from '../services/TTSService.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;
|
|
}
|
|
|
|
// Check if clicked on a word (canvas element)
|
|
if (e.target.classList.contains('floating-word')) {
|
|
e.stopPropagation();
|
|
this._handleWordClick(e.target);
|
|
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);
|
|
});
|
|
}
|
|
|
|
_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++;
|
|
}
|
|
|
|
// Calculate size based on word length (longer word = bigger trunk)
|
|
const wordLength = word.french.length;
|
|
const baseSize = 40; // Base trunk size
|
|
const sizeMultiplier = 1 + (wordLength * 0.15); // Each character adds 15% size
|
|
const trunkWidth = Math.min(baseSize * sizeMultiplier, 120); // Max trunk width
|
|
const trunkHeight = baseSize * 0.8; // Trunk is slightly flatter than wide
|
|
|
|
const wordElement = document.createElement('canvas');
|
|
wordElement.className = 'floating-word';
|
|
wordElement.width = trunkWidth;
|
|
wordElement.height = trunkHeight + 30; // Extra space for text
|
|
wordElement.style.position = 'absolute';
|
|
wordElement.style.cursor = 'pointer';
|
|
|
|
// More random positioning with different strategies
|
|
let xPosition;
|
|
const strategy = Math.random();
|
|
|
|
if (strategy < 0.4) {
|
|
xPosition = Math.random() * 80 + 10;
|
|
} else if (strategy < 0.6) {
|
|
xPosition = Math.random() * 40 + 10;
|
|
} else if (strategy < 0.8) {
|
|
xPosition = Math.random() * 40 + 50;
|
|
} else {
|
|
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.style.transform = `translateX(-${trunkWidth / 2}px)`; // Center the canvas
|
|
|
|
wordElement.wordData = word;
|
|
|
|
// Draw the trunk on the canvas
|
|
this._drawTrunk(wordElement, word.french, trunkWidth, trunkHeight);
|
|
|
|
riverCanvas.appendChild(wordElement);
|
|
this._floatingWords.push({
|
|
element: wordElement,
|
|
y: yStart,
|
|
x: xPosition,
|
|
wordData: word,
|
|
trunkWidth: trunkWidth,
|
|
trunkHeight: trunkHeight
|
|
});
|
|
|
|
if (Math.random() < 0.1) {
|
|
this._spawnPowerUp();
|
|
}
|
|
}
|
|
|
|
_drawTrunk(canvas, text, width, height) {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Draw trunk (wood texture)
|
|
const trunkY = 0;
|
|
|
|
// Wood color (brown)
|
|
const woodColor = '#8B4513';
|
|
const darkWoodColor = '#654321';
|
|
const lightWoodColor = '#A0522D';
|
|
|
|
// Draw main trunk body
|
|
ctx.fillStyle = woodColor;
|
|
ctx.beginPath();
|
|
ctx.ellipse(width / 2, trunkY + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Add wood grain texture
|
|
ctx.strokeStyle = darkWoodColor;
|
|
ctx.lineWidth = 2;
|
|
ctx.globalAlpha = 0.4;
|
|
for (let i = 0; i < 5; i++) {
|
|
const y = trunkY + (i * height / 4);
|
|
ctx.beginPath();
|
|
ctx.ellipse(width / 2, y, width / 2 - 2, 3, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Add shine/highlight
|
|
ctx.fillStyle = lightWoodColor;
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.beginPath();
|
|
ctx.ellipse(width / 3, trunkY + height / 3, width / 4, height / 4, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Draw rings (tree rings effect)
|
|
ctx.strokeStyle = darkWoodColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.globalAlpha = 0.5;
|
|
const ringCount = 3;
|
|
for (let i = 1; i <= ringCount; i++) {
|
|
const ringRatio = i / (ringCount + 1);
|
|
ctx.beginPath();
|
|
ctx.ellipse(width / 2, trunkY + height / 2, (width / 2) * ringRatio, (height / 2) * ringRatio, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Draw text on the trunk
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = `bold ${Math.max(10, width / 4)}px Arial`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
|
ctx.shadowBlur = 3;
|
|
ctx.shadowOffsetX = 1;
|
|
ctx.shadowOffsetY = 1;
|
|
|
|
ctx.fillText(text, width / 2, trunkY + height / 2);
|
|
|
|
// Add text below trunk for clarity
|
|
ctx.font = `bold ${Math.max(8, width / 5)}px Arial`;
|
|
ctx.fillText(text, width / 2, trunkY + height + 15);
|
|
}
|
|
|
|
_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) {
|
|
// Skip if already collected or missed
|
|
if (wordElement.dataset.collected === 'true' || wordElement.classList.contains('collected') || wordElement.classList.contains('missed')) {
|
|
return;
|
|
}
|
|
|
|
const wordData = wordElement.wordData;
|
|
|
|
if (wordData && this._currentTarget && 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) {
|
|
const contentLanguage = this._content?.language || 'zh-CN';
|
|
await ttsService.speak(word.trim(), contentLanguage, { rate: 0.8, volume: 1.0 });
|
|
}
|
|
|
|
_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: 75vh;
|
|
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;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
z-index: 40;
|
|
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3));
|
|
animation: wordFloat 3s ease-in-out infinite alternate;
|
|
}
|
|
|
|
@keyframes wordFloat {
|
|
from { transform: translateX(-50%) translateY(0px) rotate(-1deg); }
|
|
to { transform: translateX(-50%) translateY(-5px) rotate(1deg); }
|
|
}
|
|
|
|
.floating-word:hover {
|
|
filter: drop-shadow(0 6px 15px rgba(0,0,0,0.4));
|
|
transform: translateX(-50%) scale(1.08) !important;
|
|
}
|
|
|
|
.floating-word.collected {
|
|
animation: wordCollected 0.8s ease-out forwards;
|
|
}
|
|
|
|
@keyframes wordCollected {
|
|
0% {
|
|
transform: translateX(-50%) scale(1);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
transform: translateX(-50%) scale(1.3);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: translateX(-50%) scale(0) translateY(-50px);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.floating-word.missed {
|
|
animation: wordMissed 0.6s ease-out forwards;
|
|
}
|
|
|
|
@keyframes wordMissed {
|
|
0% {
|
|
transform: translateX(-50%) scale(1);
|
|
opacity: 1;
|
|
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3));
|
|
}
|
|
100% {
|
|
transform: translateX(-50%) scale(0.8);
|
|
opacity: 0;
|
|
filter: drop-shadow(0 0 10px rgba(220,20,60,0.8));
|
|
}
|
|
}
|
|
|
|
@keyframes wordShake {
|
|
0%, 100% {
|
|
transform: translateX(-50%) translateX(0) scale(1);
|
|
}
|
|
10%, 30%, 50%, 70%, 90% {
|
|
transform: translateX(-50%) translateX(-10px) scale(1.05);
|
|
}
|
|
20%, 40%, 60%, 80% {
|
|
transform: translateX(-50%) 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; |