Major architectural update to replace fixed 50%/100% scoring with true dynamic percentages based on content volume: • Replace old interpolation system with Math.min(100, (count/optimal)*100) formula • Add embedded compatibility methods to all 14 game modules with static requirements • Remove compatibility cache system for real-time calculation • Fix content loading to pass complete modules with vocabulary (not just metadata) • Clean up duplicate syntax errors in adventure-reader and grammar-discovery • Update navigation.js module mapping to match actual exported class names Examples of new dynamic scoring: - 15 words / 20 optimal = 75% (was 87.5% with old interpolation) - 5 words / 10 minimum = 50% (was 25% with old linear system) - 30 words / 20 optimal = 100% (unchanged) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1134 lines
36 KiB
JavaScript
1134 lines
36 KiB
JavaScript
// === RIVER RUN GAME ===
|
||
// Endless runner on a river with floating words - avoid obstacles, catch target words!
|
||
|
||
class RiverRun {
|
||
constructor({ container, content, onScoreUpdate, onGameEnd }) {
|
||
this.container = container;
|
||
this.content = content;
|
||
this.onScoreUpdate = onScoreUpdate;
|
||
this.onGameEnd = onGameEnd;
|
||
|
||
// Game state
|
||
this.isRunning = false;
|
||
this.score = 0;
|
||
this.lives = 3;
|
||
this.level = 1;
|
||
this.speed = 2; // River flow speed
|
||
this.wordsCollected = 0;
|
||
|
||
// Player
|
||
this.player = {
|
||
x: 50, // Percentage from left
|
||
y: 80, // Percentage from top
|
||
targetX: 50,
|
||
targetY: 80,
|
||
size: 40
|
||
};
|
||
|
||
// Game objects
|
||
this.floatingWords = [];
|
||
this.currentTarget = null;
|
||
this.targetQueue = [];
|
||
this.powerUps = [];
|
||
|
||
// River animation
|
||
this.riverOffset = 0;
|
||
this.particles = [];
|
||
|
||
// Timing
|
||
this.lastSpawn = 0;
|
||
this.spawnInterval = 1000; // ms between word spawns (2x faster)
|
||
this.gameStartTime = Date.now();
|
||
|
||
// Word management
|
||
this.availableWords = [];
|
||
this.usedTargets = [];
|
||
|
||
// Target word guarantee system
|
||
this.wordsSpawnedSinceTarget = 0;
|
||
this.maxWordsBeforeTarget = 10; // Guarantee target within 10 words
|
||
|
||
this.injectCSS();
|
||
this.extractContent();
|
||
this.init();
|
||
}
|
||
|
||
injectCSS() {
|
||
if (document.getElementById('river-run-styles')) return;
|
||
|
||
const styleSheet = document.createElement('style');
|
||
styleSheet.id = 'river-run-styles';
|
||
styleSheet.textContent = `
|
||
.river-run-wrapper {
|
||
background: linear-gradient(180deg, #87CEEB 0%, #4682B4 50%, #2F4F4F 100%);
|
||
position: relative;
|
||
overflow: hidden;
|
||
height: 100vh;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
/* Words are neutral at spawn - styling happens at interaction */
|
||
|
||
.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);
|
||
}
|
||
}
|
||
|
||
.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); }
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.level-indicator {
|
||
position: absolute;
|
||
top: 70px;
|
||
left: 20px;
|
||
background: rgba(255,255,255,0.9);
|
||
color: #333;
|
||
padding: 5px 15px;
|
||
border-radius: 15px;
|
||
font-size: 0.9em;
|
||
font-weight: bold;
|
||
z-index: 100;
|
||
}
|
||
|
||
/* Responsive */
|
||
@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;
|
||
}
|
||
}
|
||
`;
|
||
document.head.appendChild(styleSheet);
|
||
}
|
||
|
||
extractContent() {
|
||
logSh('🌊 River Run - Extracting vocabulary...', 'INFO');
|
||
|
||
// Extract words from various content formats
|
||
if (this.content.vocabulary) {
|
||
Object.keys(this.content.vocabulary).forEach(word => {
|
||
const wordData = this.content.vocabulary[word];
|
||
this.availableWords.push({
|
||
french: word,
|
||
english: typeof wordData === 'string' ? wordData :
|
||
wordData.translation || wordData.user_language || 'unknown',
|
||
pronunciation: wordData.pronunciation || wordData.prononciation
|
||
});
|
||
});
|
||
}
|
||
|
||
// Fallback: extract from letter structure if available
|
||
if (this.content.letters && this.availableWords.length === 0) {
|
||
Object.values(this.content.letters).forEach(letterWords => {
|
||
letterWords.forEach(wordData => {
|
||
this.availableWords.push({
|
||
french: wordData.word,
|
||
english: wordData.translation,
|
||
pronunciation: wordData.pronunciation
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (this.availableWords.length === 0) {
|
||
throw new Error('No vocabulary found for River Run');
|
||
}
|
||
|
||
logSh(`🎯 River Run ready: ${this.availableWords.length} words available`, 'INFO');
|
||
this.generateTargetQueue();
|
||
}
|
||
|
||
generateTargetQueue() {
|
||
// Create queue of targets, ensuring variety
|
||
this.targetQueue = this.shuffleArray([...this.availableWords]).slice(0, Math.min(10, this.availableWords.length));
|
||
this.usedTargets = [];
|
||
}
|
||
|
||
init() {
|
||
this.container.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>
|
||
`;
|
||
|
||
this.setupEventListeners();
|
||
this.updateHUD();
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
// Handle floating word clicks
|
||
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();
|
||
|
||
// Start game loop
|
||
this.gameLoop();
|
||
|
||
logSh('🌊 River Run started!', 'INFO');
|
||
}
|
||
|
||
gameLoop() {
|
||
if (!this.isRunning) return;
|
||
|
||
const now = Date.now();
|
||
|
||
// Spawn new words
|
||
if (now - this.lastSpawn > this.spawnInterval) {
|
||
this.spawnFloatingWord();
|
||
this.lastSpawn = now;
|
||
}
|
||
|
||
// Update game objects
|
||
this.updateFloatingWords();
|
||
this.updatePlayer();
|
||
this.updateParticles();
|
||
this.checkCollisions();
|
||
|
||
// Increase difficulty over time
|
||
this.updateDifficulty();
|
||
|
||
// Update UI
|
||
this.updateHUD();
|
||
|
||
// Continue loop
|
||
requestAnimationFrame(() => this.gameLoop());
|
||
}
|
||
|
||
setNextTarget() {
|
||
if (this.targetQueue.length === 0) {
|
||
this.generateTargetQueue();
|
||
}
|
||
|
||
this.currentTarget = this.targetQueue.shift();
|
||
this.usedTargets.push(this.currentTarget);
|
||
|
||
// Reset the word counter for new target
|
||
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;
|
||
|
||
// Determine if we should force the target word
|
||
let word;
|
||
if (this.wordsSpawnedSinceTarget >= this.maxWordsBeforeTarget) {
|
||
// Force target word to appear
|
||
word = this.currentTarget;
|
||
this.wordsSpawnedSinceTarget = 0; // Reset counter
|
||
logSh(`🎯 Forcing target word: ${word.french}`, 'DEBUG');
|
||
} else {
|
||
// Spawn random word
|
||
word = this.getRandomWord();
|
||
this.wordsSpawnedSinceTarget++;
|
||
}
|
||
|
||
const wordElement = document.createElement('div');
|
||
wordElement.className = 'floating-word'; // No target/obstacle class at spawn
|
||
|
||
// Add spaces based on level for increased difficulty
|
||
const spacePadding = ' '.repeat(this.level * 2); // 2 spaces per level on each side
|
||
wordElement.textContent = spacePadding + word.french + spacePadding;
|
||
|
||
wordElement.style.left = `${Math.random() * 80 + 10}%`;
|
||
wordElement.style.top = '-60px';
|
||
|
||
// Store word data only
|
||
wordElement.wordData = word;
|
||
|
||
riverCanvas.appendChild(wordElement);
|
||
this.floatingWords.push({
|
||
element: wordElement,
|
||
y: -60,
|
||
x: parseFloat(wordElement.style.left),
|
||
wordData: word
|
||
});
|
||
|
||
// Occasional power-up spawn
|
||
if (Math.random() < 0.1) {
|
||
this.spawnPowerUp();
|
||
}
|
||
}
|
||
|
||
getRandomWord() {
|
||
// Simply return any random word from available vocabulary
|
||
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 = '⚡';
|
||
powerUpElement.style.left = `${Math.random() * 80 + 10}%`;
|
||
powerUpElement.style.top = '-40px';
|
||
|
||
riverCanvas.appendChild(powerUpElement);
|
||
this.powerUps.push({
|
||
element: powerUpElement,
|
||
y: -40,
|
||
x: parseFloat(powerUpElement.style.left),
|
||
type: 'slowTime'
|
||
});
|
||
}
|
||
|
||
updateFloatingWords() {
|
||
this.floatingWords = this.floatingWords.filter(word => {
|
||
word.y += this.speed;
|
||
word.element.style.top = `${word.y}px`;
|
||
|
||
// Remove words that went off screen
|
||
if (word.y > window.innerHeight + 60) {
|
||
// CHECK AT EXIT TIME: Was this the target word?
|
||
if (word.wordData.french === this.currentTarget.french) {
|
||
// Missed target word - lose life
|
||
this.loseLife();
|
||
}
|
||
word.element.remove();
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
// Update power-ups
|
||
this.powerUps = this.powerUps.filter(powerUp => {
|
||
powerUp.y += this.speed;
|
||
powerUp.element.style.top = `${powerUp.y}px`;
|
||
|
||
if (powerUp.y > window.innerHeight + 40) {
|
||
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);
|
||
}
|
||
|
||
// Create ripple effect
|
||
this.createRippleEffect(targetX, targetY);
|
||
}
|
||
|
||
updatePlayer() {
|
||
// Smooth movement towards target
|
||
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() {
|
||
// Create water particles occasionally
|
||
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
|
||
this.floatingWords.forEach((word, index) => {
|
||
const wordRect = this.getElementRect(word.element);
|
||
|
||
if (this.isColliding(playerRect, wordRect)) {
|
||
this.handleWordCollision(word, index);
|
||
}
|
||
});
|
||
|
||
// Check power-up collisions
|
||
this.powerUps.forEach((powerUp, index) => {
|
||
const powerUpRect = this.getElementRect(powerUp.element);
|
||
|
||
if (this.isColliding(playerRect, powerUpRect)) {
|
||
this.handlePowerUpCollision(powerUp, index);
|
||
}
|
||
});
|
||
}
|
||
|
||
getPlayerRect() {
|
||
const playerElement = document.getElementById('player');
|
||
if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 };
|
||
|
||
const rect = playerElement.getBoundingClientRect();
|
||
const canvas = document.getElementById('river-canvas').getBoundingClientRect();
|
||
|
||
return {
|
||
x: rect.left - canvas.left,
|
||
y: rect.top - canvas.top,
|
||
width: rect.width,
|
||
height: rect.height
|
||
};
|
||
}
|
||
|
||
getElementRect(element) {
|
||
const rect = element.getBoundingClientRect();
|
||
const canvas = document.getElementById('river-canvas').getBoundingClientRect();
|
||
|
||
return {
|
||
x: rect.left - canvas.left,
|
||
y: rect.top - canvas.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;
|
||
|
||
// CHECK AT PICK TIME: Is this the target word?
|
||
if (wordData.french === this.currentTarget.french) {
|
||
// Correct target word clicked
|
||
this.collectWord(wordElement, true);
|
||
} else {
|
||
// Wrong word clicked - it's an obstacle
|
||
this.missWord(wordElement);
|
||
}
|
||
}
|
||
|
||
handleWordCollision(word, index) {
|
||
// CHECK AT COLLISION TIME: Is this the target word?
|
||
if (word.wordData.french === this.currentTarget.french) {
|
||
this.collectWord(word.element, true);
|
||
} else {
|
||
// Collision with non-target word = obstacle hit
|
||
this.missWord(word.element);
|
||
}
|
||
|
||
// Remove from array
|
||
this.floatingWords.splice(index, 1);
|
||
}
|
||
|
||
collectWord(wordElement, isCorrect) {
|
||
wordElement.classList.add('collected');
|
||
|
||
if (isCorrect) {
|
||
this.score += 10 + (this.level * 2);
|
||
this.wordsCollected++;
|
||
this.onScoreUpdate(this.score);
|
||
|
||
// Set next target
|
||
this.setNextTarget();
|
||
|
||
// Play success sound
|
||
this.playSuccessSound(wordElement.textContent);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
wordElement.remove();
|
||
}, 800);
|
||
}
|
||
|
||
missWord(wordElement) {
|
||
wordElement.classList.add('missed');
|
||
this.loseLife();
|
||
|
||
setTimeout(() => {
|
||
wordElement.remove();
|
||
}, 600);
|
||
}
|
||
|
||
handlePowerUpCollision(powerUp, index) {
|
||
this.activatePowerUp(powerUp.type);
|
||
powerUp.element.remove();
|
||
this.powerUps.splice(index, 1);
|
||
}
|
||
|
||
activatePowerUp(type) {
|
||
switch (type) {
|
||
case 'slowTime':
|
||
this.speed *= 0.5;
|
||
setTimeout(() => {
|
||
this.speed *= 2;
|
||
}, 3000);
|
||
break;
|
||
}
|
||
}
|
||
|
||
updateDifficulty() {
|
||
const timeElapsed = Date.now() - this.gameStartTime;
|
||
const newLevel = Math.floor(timeElapsed / 30000) + 1; // Level up every 30 seconds
|
||
|
||
if (newLevel > this.level) {
|
||
this.level = newLevel;
|
||
this.speed += 0.5;
|
||
this.spawnInterval = Math.max(500, this.spawnInterval - 100); // More aggressive spawn increase
|
||
}
|
||
}
|
||
|
||
playSuccessSound(word) {
|
||
if (window.SettingsManager && window.SettingsManager.speak) {
|
||
window.SettingsManager.speak(word, {
|
||
lang: this.content.language || 'fr-FR',
|
||
rate: 1.0
|
||
}).catch(error => {
|
||
console.warn('🔊 TTS failed:', error);
|
||
});
|
||
}
|
||
}
|
||
|
||
loseLife() {
|
||
this.lives--;
|
||
|
||
if (this.lives <= 0) {
|
||
this.gameOver();
|
||
}
|
||
}
|
||
|
||
gameOver() {
|
||
this.isRunning = false;
|
||
|
||
const riverGame = document.getElementById('river-game');
|
||
const accuracy = this.wordsCollected > 0 ? Math.round((this.wordsCollected / (this.wordsCollected + (3 - this.lives))) * 100) : 0;
|
||
|
||
const gameOverModal = document.createElement('div');
|
||
gameOverModal.className = 'game-over-modal';
|
||
gameOverModal.innerHTML = `
|
||
<div class="game-over-title">🌊 River Complete!</div>
|
||
<div class="game-over-stats">
|
||
Final Score: ${this.score}<br>
|
||
Words Collected: ${this.wordsCollected}<br>
|
||
Level Reached: ${this.level}<br>
|
||
Accuracy: ${accuracy}%
|
||
</div>
|
||
<div>
|
||
<button class="river-btn" onclick="window.currentRiverGame.restart()">
|
||
🔄 Sail Again
|
||
</button>
|
||
<button class="river-btn" onclick="window.currentRiverGame.onGameEnd(${this.score})">
|
||
🏠 Back to Games
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
riverGame.appendChild(gameOverModal);
|
||
|
||
// Store reference for button callbacks
|
||
window.currentRiverGame = this;
|
||
|
||
setTimeout(() => {
|
||
this.onGameEnd(this.score);
|
||
}, 5000);
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
restart() {
|
||
// Reset game state
|
||
this.isRunning = false;
|
||
this.score = 0;
|
||
this.lives = 3;
|
||
this.level = 1;
|
||
this.speed = 2;
|
||
this.wordsCollected = 0;
|
||
this.riverOffset = 0;
|
||
|
||
// Reset player position
|
||
this.player.x = 50;
|
||
this.player.y = 80;
|
||
this.player.targetX = 50;
|
||
this.player.targetY = 80;
|
||
|
||
// Clear game objects
|
||
this.floatingWords = [];
|
||
this.powerUps = [];
|
||
this.particles = [];
|
||
|
||
// Reset timing
|
||
this.lastSpawn = 0;
|
||
this.spawnInterval = 1000; // 2x faster spawn rate
|
||
this.gameStartTime = Date.now();
|
||
|
||
// Reset targets and word counter
|
||
this.wordsSpawnedSinceTarget = 0;
|
||
this.generateTargetQueue();
|
||
|
||
// Cleanup DOM
|
||
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();
|
||
}
|
||
|
||
// Reset target display
|
||
const targetDisplay = document.getElementById('target-display');
|
||
if (targetDisplay) {
|
||
targetDisplay.textContent = 'Click to Start!';
|
||
}
|
||
|
||
this.updateHUD();
|
||
|
||
logSh('🔄 River Run restarted', 'INFO');
|
||
}
|
||
|
||
destroy() {
|
||
this.isRunning = false;
|
||
|
||
// Cleanup
|
||
if (window.currentRiverGame === this) {
|
||
delete window.currentRiverGame;
|
||
}
|
||
|
||
const styleSheet = document.getElementById('river-run-styles');
|
||
if (styleSheet) {
|
||
styleSheet.remove();
|
||
}
|
||
}
|
||
|
||
// === COMPATIBILITY SYSTEM ===
|
||
static getCompatibilityRequirements() {
|
||
return {
|
||
minimum: {
|
||
vocabulary: 10
|
||
},
|
||
optimal: {
|
||
vocabulary: 25
|
||
},
|
||
name: "River Run",
|
||
description: "Vocabulary collection game where player navigates river to collect target words"
|
||
};
|
||
}
|
||
|
||
static checkContentCompatibility(content) {
|
||
const requirements = RiverRun.getCompatibilityRequirements();
|
||
|
||
// Extract vocabulary using same method as instance
|
||
const vocabulary = RiverRun.extractVocabularyStatic(content);
|
||
const vocabCount = vocabulary.length;
|
||
|
||
// Dynamic percentage based on optimal volume (10 min → 25 optimal)
|
||
// 0 words = 0%, 12 words = 48%, 25 words = 100%
|
||
const score = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
|
||
|
||
return {
|
||
score: Math.round(score),
|
||
details: {
|
||
vocabulary: {
|
||
found: vocabCount,
|
||
minimum: requirements.minimum.vocabulary,
|
||
optimal: requirements.optimal.vocabulary,
|
||
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
|
||
}
|
||
},
|
||
recommendations: vocabCount < requirements.optimal.vocabulary ?
|
||
[`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words for optimal experience`] :
|
||
[]
|
||
};
|
||
}
|
||
|
||
static extractVocabularyStatic(content) {
|
||
let vocabulary = [];
|
||
|
||
// Priority 1: Use raw module content (simple format)
|
||
if (content.rawContent) {
|
||
return RiverRun.extractVocabularyFromRawStatic(content.rawContent);
|
||
}
|
||
|
||
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
|
||
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
word: word,
|
||
translation: data.user_language.split(';')[0],
|
||
fullTranslation: data.user_language,
|
||
type: data.type || 'general',
|
||
audio: data.audio,
|
||
image: data.image,
|
||
examples: data.examples,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
word: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
return RiverRun.finalizeVocabularyStatic(vocabulary);
|
||
}
|
||
|
||
static extractVocabularyFromRawStatic(rawContent) {
|
||
let vocabulary = [];
|
||
|
||
// Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
|
||
vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
word: word,
|
||
translation: data.user_language.split(';')[0],
|
||
fullTranslation: data.user_language,
|
||
type: data.type || 'general',
|
||
audio: data.audio,
|
||
image: data.image,
|
||
examples: data.examples,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
word: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
return RiverRun.finalizeVocabularyStatic(vocabulary);
|
||
}
|
||
|
||
static finalizeVocabularyStatic(vocabulary) {
|
||
// Validation and cleanup for ultra-modular format
|
||
vocabulary = vocabulary.filter(word =>
|
||
word &&
|
||
typeof word.word === 'string' &&
|
||
typeof word.translation === 'string' &&
|
||
word.word.trim() !== '' &&
|
||
word.translation.trim() !== ''
|
||
);
|
||
|
||
return vocabulary;
|
||
}
|
||
}
|
||
|
||
// Add CSS animations
|
||
const additionalCSS = `
|
||
@keyframes particleSpread {
|
||
0% {
|
||
transform: scale(1) translate(0, 0);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: scale(0) translate(${Math.random() * 100 - 50}px, ${Math.random() * 100 - 50}px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes particleFlow {
|
||
0% {
|
||
transform: translateY(0);
|
||
opacity: 0.7;
|
||
}
|
||
100% {
|
||
transform: translateY(100vh);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`;
|
||
|
||
// Inject additional CSS
|
||
const additionalStyleSheet = document.createElement('style');
|
||
additionalStyleSheet.textContent = additionalCSS;
|
||
document.head.appendChild(additionalStyleSheet);
|
||
|
||
// Register the game module
|
||
window.GameModules = window.GameModules || {};
|
||
window.GameModules.RiverRun = RiverRun; |