feat: add Worlds 2-3, patch persistence, and zoom controls
- World 2 (Filtros): 8 levels teaching filters, resonance, LFO modulation, acid bass - World 3 (Envelopes): 8 levels teaching VCA, ADSR, pluck, tremolo, full synth lead - Star-based world unlock system (12 stars for W2, 24 for W3) - Level patch persistence: auto-saves player patches, restores on revisit - Google Maps-style zoom controls (+/−/reset) in both puzzle and sandbox views - Multi-world navigation in GameApp and WorldMap - Target audio now supports filter chain for World 2 levels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { getLevelProgress, isLevelUnlocked } from './gameState.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1];
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
@@ -14,13 +16,23 @@ function Stars({ count, max = 3 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalStars() {
|
||||
const p = loadProgress();
|
||||
return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
}
|
||||
|
||||
function getMaxStars() {
|
||||
return worlds.reduce((s, w) => s + w.levels.length * 3, 0);
|
||||
}
|
||||
|
||||
function isWorldUnlocked(world) {
|
||||
if (!world.unlockStars) return true; // World 1 always unlocked
|
||||
return getTotalStars() >= world.unlockStars;
|
||||
}
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox }) {
|
||||
const world = WORLD_1;
|
||||
const totalStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
return s + (p?.stars || 0);
|
||||
}, 0);
|
||||
const maxStars = world.levels.length * 3;
|
||||
const totalStars = getTotalStars();
|
||||
const maxStars = getMaxStars();
|
||||
|
||||
return (
|
||||
<div className="gm-worldmap">
|
||||
@@ -43,69 +55,74 @@ export default function WorldMap({ onSelectLevel, onSandbox }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* World section */}
|
||||
<div className="gm-world-section">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name">Mundo 1: {world.name}</h2>
|
||||
<p className="gm-world-sub">{world.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* All worlds */}
|
||||
{worlds.map((world, worldIdx) => {
|
||||
const unlocked = isWorldUnlocked(world);
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
return s + (p?.stars || 0);
|
||||
}, 0);
|
||||
const worldMaxStars = world.levels.length * 3;
|
||||
|
||||
{/* Level grid */}
|
||||
<div className="gm-level-grid">
|
||||
{world.levels.map((level, idx) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const unlocked = isLevelUnlocked(level.id, world.levels);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${unlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => unlocked && onSelectLevel(level)}
|
||||
>
|
||||
<div className="gm-level-number">{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{level.subtitle}</p>
|
||||
if (!unlocked) {
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">Consigue {world.unlockStars} estrellas para desbloquear ({totalStars}/{world.unlockStars})</p>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!unlocked && <div className="gm-lock-overlay" />}
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Future worlds teaser */}
|
||||
<div className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>▽</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 2: Filtros</h2>
|
||||
<p className="gm-world-sub">Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear</p>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name">Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">{world.subtitle}</p>
|
||||
</div>
|
||||
<div className="gm-world-stars">
|
||||
<span className="star filled">★</span> {worldStars}/{worldMaxStars}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>⏤╲</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 3: Envelopes</h2>
|
||||
<p className="gm-world-sub">Proximamente...</p>
|
||||
<div className="gm-level-grid">
|
||||
{world.levels.map((level, idx) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const levelUnlocked = isLevelUnlocked(level.id, world.levels);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => levelUnlocked && onSelectLevel(level, world)}
|
||||
>
|
||||
<div className="gm-level-number">{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{level.subtitle}</p>
|
||||
</div>
|
||||
{levelUnlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!levelUnlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user