Spaces:
Paused
Paused
asemxin commited on
Commit ·
4539eae
1
Parent(s): 2d54981
Add multiple levels and sound effects system
Browse files- app/components/GameCanvas.tsx +10 -5
- app/components/StartScreen.tsx +26 -2
- app/globals.css +76 -19
- app/page.tsx +8 -2
- lib/constants.ts +4 -4
- lib/engine/AudioManager.ts +219 -0
- lib/engine/Game.ts +97 -16
- lib/engine/Level2.ts +204 -0
- lib/engine/Level3.ts +213 -0
- lib/engine/LevelManager.ts +93 -0
- lib/engine/Renderer.ts +69 -7
app/components/GameCanvas.tsx
CHANGED
|
@@ -1,30 +1,35 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import { useEffect, useRef } from 'react';
|
| 4 |
import { Game } from '@/lib/engine/Game';
|
| 5 |
import { GAME_CONFIG } from '@/lib/constants';
|
| 6 |
|
| 7 |
interface GameCanvasProps {
|
| 8 |
onGameOver: (isWin: boolean, score: number) => void;
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
export default function GameCanvas({ onGameOver }: GameCanvasProps) {
|
| 12 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 13 |
const gameRef = useRef<Game | null>(null);
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
useEffect(() => {
|
| 16 |
if (!canvasRef.current) return;
|
| 17 |
|
| 18 |
-
const game = new Game(canvasRef.current);
|
| 19 |
gameRef.current = game;
|
| 20 |
|
| 21 |
-
game.setOnGameOver(
|
| 22 |
game.start();
|
| 23 |
|
| 24 |
return () => {
|
| 25 |
game.stop();
|
| 26 |
};
|
| 27 |
-
}, [
|
| 28 |
|
| 29 |
return (
|
| 30 |
<canvas
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import { useEffect, useRef, useCallback } from 'react';
|
| 4 |
import { Game } from '@/lib/engine/Game';
|
| 5 |
import { GAME_CONFIG } from '@/lib/constants';
|
| 6 |
|
| 7 |
interface GameCanvasProps {
|
| 8 |
onGameOver: (isWin: boolean, score: number) => void;
|
| 9 |
+
startLevel?: number;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
export default function GameCanvas({ onGameOver, startLevel = 0 }: GameCanvasProps) {
|
| 13 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 14 |
const gameRef = useRef<Game | null>(null);
|
| 15 |
|
| 16 |
+
const handleGameOver = useCallback((isWin: boolean, score: number) => {
|
| 17 |
+
onGameOver(isWin, score);
|
| 18 |
+
}, [onGameOver]);
|
| 19 |
+
|
| 20 |
useEffect(() => {
|
| 21 |
if (!canvasRef.current) return;
|
| 22 |
|
| 23 |
+
const game = new Game(canvasRef.current, startLevel);
|
| 24 |
gameRef.current = game;
|
| 25 |
|
| 26 |
+
game.setOnGameOver(handleGameOver);
|
| 27 |
game.start();
|
| 28 |
|
| 29 |
return () => {
|
| 30 |
game.stop();
|
| 31 |
};
|
| 32 |
+
}, [handleGameOver, startLevel]);
|
| 33 |
|
| 34 |
return (
|
| 35 |
<canvas
|
app/components/StartScreen.tsx
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
'use client';
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
interface StartScreenProps {
|
| 4 |
-
onStart: () => void;
|
| 5 |
}
|
| 6 |
|
| 7 |
export default function StartScreen({ onStart }: StartScreenProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
return (
|
| 9 |
<div className="start-screen">
|
| 10 |
<div className="title-container">
|
|
@@ -13,7 +25,19 @@ export default function StartScreen({ onStart }: StartScreenProps) {
|
|
| 13 |
<p className="subtitle">WEB EDITION</p>
|
| 14 |
</div>
|
| 15 |
|
| 16 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
START GAME
|
| 18 |
</button>
|
| 19 |
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import { getAudioManager } from '@/lib/engine/AudioManager';
|
| 4 |
+
import { LevelManager } from '@/lib/engine/LevelManager';
|
| 5 |
+
|
| 6 |
interface StartScreenProps {
|
| 7 |
+
onStart: (levelIndex?: number) => void;
|
| 8 |
}
|
| 9 |
|
| 10 |
export default function StartScreen({ onStart }: StartScreenProps) {
|
| 11 |
+
const levelManager = new LevelManager();
|
| 12 |
+
const levels = levelManager.getAllLevels();
|
| 13 |
+
|
| 14 |
+
const handleToggleSound = () => {
|
| 15 |
+
const audioManager = getAudioManager();
|
| 16 |
+
audioManager.toggleMute();
|
| 17 |
+
// Force re-render would be needed for UI update
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
return (
|
| 21 |
<div className="start-screen">
|
| 22 |
<div className="title-container">
|
|
|
|
| 25 |
<p className="subtitle">WEB EDITION</p>
|
| 26 |
</div>
|
| 27 |
|
| 28 |
+
<div className="level-buttons">
|
| 29 |
+
{levels.map((level, index) => (
|
| 30 |
+
<button
|
| 31 |
+
key={level.id}
|
| 32 |
+
className={`level-button ${level.theme}`}
|
| 33 |
+
onClick={() => onStart(index)}
|
| 34 |
+
>
|
| 35 |
+
{level.name}
|
| 36 |
+
</button>
|
| 37 |
+
))}
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<button className="start-button" onClick={() => onStart(0)}>
|
| 41 |
START GAME
|
| 42 |
</button>
|
| 43 |
|
app/globals.css
CHANGED
|
@@ -39,15 +39,23 @@ body {
|
|
| 39 |
}
|
| 40 |
|
| 41 |
@keyframes blink {
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
/* Game Canvas */
|
| 47 |
.game-canvas {
|
| 48 |
border: 4px solid #333;
|
| 49 |
border-radius: 8px;
|
| 50 |
-
box-shadow:
|
| 51 |
0 0 0 4px var(--mario-red),
|
| 52 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 53 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
@@ -66,7 +74,7 @@ body {
|
|
| 66 |
background: linear-gradient(180deg, var(--sky-blue) 0%, #87ceeb 60%, var(--mario-green) 60%, #2d5a27 100%);
|
| 67 |
border: 4px solid #333;
|
| 68 |
border-radius: 8px;
|
| 69 |
-
box-shadow:
|
| 70 |
0 0 0 4px var(--mario-red),
|
| 71 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 72 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
@@ -83,7 +91,7 @@ body {
|
|
| 83 |
height: 40px;
|
| 84 |
background: white;
|
| 85 |
border-radius: 50%;
|
| 86 |
-
box-shadow:
|
| 87 |
100px 20px 0 30px white,
|
| 88 |
250px -10px 0 20px white,
|
| 89 |
400px 10px 0 25px white;
|
|
@@ -99,7 +107,7 @@ body {
|
|
| 99 |
.game-title {
|
| 100 |
font-size: 48px;
|
| 101 |
color: white;
|
| 102 |
-
text-shadow:
|
| 103 |
4px 4px 0 #000,
|
| 104 |
-2px -2px 0 #000,
|
| 105 |
2px -2px 0 #000,
|
|
@@ -115,8 +123,13 @@ body {
|
|
| 115 |
}
|
| 116 |
|
| 117 |
@keyframes bounce {
|
| 118 |
-
from {
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
.subtitle {
|
|
@@ -127,6 +140,49 @@ body {
|
|
| 127 |
letter-spacing: 6px;
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
.start-button {
|
| 131 |
font-family: 'Press Start 2P', cursive;
|
| 132 |
font-size: 18px;
|
|
@@ -137,7 +193,7 @@ body {
|
|
| 137 |
border-radius: 8px;
|
| 138 |
cursor: pointer;
|
| 139 |
text-shadow: 2px 2px 0 #000;
|
| 140 |
-
box-shadow:
|
| 141 |
0 6px 0 #8b0000,
|
| 142 |
0 8px 20px rgba(0, 0, 0, 0.4);
|
| 143 |
transition: all 0.1s ease;
|
|
@@ -146,14 +202,14 @@ body {
|
|
| 146 |
|
| 147 |
.start-button:hover {
|
| 148 |
transform: translateY(-2px);
|
| 149 |
-
box-shadow:
|
| 150 |
0 8px 0 #8b0000,
|
| 151 |
0 10px 25px rgba(0, 0, 0, 0.5);
|
| 152 |
}
|
| 153 |
|
| 154 |
.start-button:active {
|
| 155 |
transform: translateY(4px);
|
| 156 |
-
box-shadow:
|
| 157 |
0 2px 0 #8b0000,
|
| 158 |
0 4px 10px rgba(0, 0, 0, 0.4);
|
| 159 |
}
|
|
@@ -209,7 +265,7 @@ body {
|
|
| 209 |
height: 480px;
|
| 210 |
border: 4px solid #333;
|
| 211 |
border-radius: 8px;
|
| 212 |
-
box-shadow:
|
| 213 |
0 0 0 4px var(--mario-red),
|
| 214 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 215 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
@@ -230,7 +286,7 @@ body {
|
|
| 230 |
.result-title {
|
| 231 |
font-size: 32px;
|
| 232 |
color: white;
|
| 233 |
-
text-shadow:
|
| 234 |
4px 4px 0 #000,
|
| 235 |
-2px -2px 0 #000;
|
| 236 |
margin-bottom: 30px;
|
|
@@ -275,7 +331,7 @@ body {
|
|
| 275 |
border-radius: 8px;
|
| 276 |
cursor: pointer;
|
| 277 |
text-shadow: 2px 2px 0 #000;
|
| 278 |
-
box-shadow:
|
| 279 |
0 6px 0 #025a7c,
|
| 280 |
0 8px 20px rgba(0, 0, 0, 0.4);
|
| 281 |
transition: all 0.1s ease;
|
|
@@ -284,20 +340,21 @@ body {
|
|
| 284 |
|
| 285 |
.restart-button:hover {
|
| 286 |
transform: translateY(-2px);
|
| 287 |
-
box-shadow:
|
| 288 |
0 8px 0 #025a7c,
|
| 289 |
0 10px 25px rgba(0, 0, 0, 0.5);
|
| 290 |
}
|
| 291 |
|
| 292 |
.restart-button:active {
|
| 293 |
transform: translateY(4px);
|
| 294 |
-
box-shadow:
|
| 295 |
0 2px 0 #025a7c,
|
| 296 |
0 4px 10px rgba(0, 0, 0, 0.4);
|
| 297 |
}
|
| 298 |
|
| 299 |
/* Responsive */
|
| 300 |
@media (max-width: 850px) {
|
|
|
|
| 301 |
.game-canvas,
|
| 302 |
.start-screen,
|
| 303 |
.game-over-screen {
|
|
@@ -306,12 +363,12 @@ body {
|
|
| 306 |
height: auto;
|
| 307 |
aspect-ratio: 800 / 480;
|
| 308 |
}
|
| 309 |
-
|
| 310 |
.game-title {
|
| 311 |
font-size: 32px;
|
| 312 |
}
|
| 313 |
-
|
| 314 |
.game-title.mario {
|
| 315 |
font-size: 40px;
|
| 316 |
}
|
| 317 |
-
}
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
@keyframes blink {
|
| 42 |
+
|
| 43 |
+
0%,
|
| 44 |
+
50% {
|
| 45 |
+
opacity: 1;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
51%,
|
| 49 |
+
100% {
|
| 50 |
+
opacity: 0;
|
| 51 |
+
}
|
| 52 |
}
|
| 53 |
|
| 54 |
/* Game Canvas */
|
| 55 |
.game-canvas {
|
| 56 |
border: 4px solid #333;
|
| 57 |
border-radius: 8px;
|
| 58 |
+
box-shadow:
|
| 59 |
0 0 0 4px var(--mario-red),
|
| 60 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 61 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
|
|
| 74 |
background: linear-gradient(180deg, var(--sky-blue) 0%, #87ceeb 60%, var(--mario-green) 60%, #2d5a27 100%);
|
| 75 |
border: 4px solid #333;
|
| 76 |
border-radius: 8px;
|
| 77 |
+
box-shadow:
|
| 78 |
0 0 0 4px var(--mario-red),
|
| 79 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 80 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
|
|
| 91 |
height: 40px;
|
| 92 |
background: white;
|
| 93 |
border-radius: 50%;
|
| 94 |
+
box-shadow:
|
| 95 |
100px 20px 0 30px white,
|
| 96 |
250px -10px 0 20px white,
|
| 97 |
400px 10px 0 25px white;
|
|
|
|
| 107 |
.game-title {
|
| 108 |
font-size: 48px;
|
| 109 |
color: white;
|
| 110 |
+
text-shadow:
|
| 111 |
4px 4px 0 #000,
|
| 112 |
-2px -2px 0 #000,
|
| 113 |
2px -2px 0 #000,
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
@keyframes bounce {
|
| 126 |
+
from {
|
| 127 |
+
transform: translateY(0);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
to {
|
| 131 |
+
transform: translateY(-8px);
|
| 132 |
+
}
|
| 133 |
}
|
| 134 |
|
| 135 |
.subtitle {
|
|
|
|
| 140 |
letter-spacing: 6px;
|
| 141 |
}
|
| 142 |
|
| 143 |
+
/* Level Selection */
|
| 144 |
+
.level-buttons {
|
| 145 |
+
display: flex;
|
| 146 |
+
gap: 10px;
|
| 147 |
+
margin-bottom: 15px;
|
| 148 |
+
z-index: 1;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.level-button {
|
| 152 |
+
font-family: 'Press Start 2P', cursive;
|
| 153 |
+
font-size: 10px;
|
| 154 |
+
padding: 10px 15px;
|
| 155 |
+
color: white;
|
| 156 |
+
border: none;
|
| 157 |
+
border-radius: 6px;
|
| 158 |
+
cursor: pointer;
|
| 159 |
+
text-shadow: 1px 1px 0 #000;
|
| 160 |
+
transition: all 0.1s ease;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.level-button.overworld {
|
| 164 |
+
background: linear-gradient(180deg, var(--sky-blue) 0%, #4a7acc 100%);
|
| 165 |
+
box-shadow: 0 4px 0 #3a5a9c;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.level-button.underground {
|
| 169 |
+
background: linear-gradient(180deg, #4a4a6a 0%, #2a2a4a 100%);
|
| 170 |
+
box-shadow: 0 4px 0 #1a1a3a;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.level-button.castle {
|
| 174 |
+
background: linear-gradient(180deg, #8b3030 0%, #5a2020 100%);
|
| 175 |
+
box-shadow: 0 4px 0 #3a1010;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.level-button:hover {
|
| 179 |
+
transform: translateY(-2px);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.level-button:active {
|
| 183 |
+
transform: translateY(2px);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
.start-button {
|
| 187 |
font-family: 'Press Start 2P', cursive;
|
| 188 |
font-size: 18px;
|
|
|
|
| 193 |
border-radius: 8px;
|
| 194 |
cursor: pointer;
|
| 195 |
text-shadow: 2px 2px 0 #000;
|
| 196 |
+
box-shadow:
|
| 197 |
0 6px 0 #8b0000,
|
| 198 |
0 8px 20px rgba(0, 0, 0, 0.4);
|
| 199 |
transition: all 0.1s ease;
|
|
|
|
| 202 |
|
| 203 |
.start-button:hover {
|
| 204 |
transform: translateY(-2px);
|
| 205 |
+
box-shadow:
|
| 206 |
0 8px 0 #8b0000,
|
| 207 |
0 10px 25px rgba(0, 0, 0, 0.5);
|
| 208 |
}
|
| 209 |
|
| 210 |
.start-button:active {
|
| 211 |
transform: translateY(4px);
|
| 212 |
+
box-shadow:
|
| 213 |
0 2px 0 #8b0000,
|
| 214 |
0 4px 10px rgba(0, 0, 0, 0.4);
|
| 215 |
}
|
|
|
|
| 265 |
height: 480px;
|
| 266 |
border: 4px solid #333;
|
| 267 |
border-radius: 8px;
|
| 268 |
+
box-shadow:
|
| 269 |
0 0 0 4px var(--mario-red),
|
| 270 |
0 0 30px rgba(229, 37, 33, 0.3),
|
| 271 |
0 20px 60px rgba(0, 0, 0, 0.5);
|
|
|
|
| 286 |
.result-title {
|
| 287 |
font-size: 32px;
|
| 288 |
color: white;
|
| 289 |
+
text-shadow:
|
| 290 |
4px 4px 0 #000,
|
| 291 |
-2px -2px 0 #000;
|
| 292 |
margin-bottom: 30px;
|
|
|
|
| 331 |
border-radius: 8px;
|
| 332 |
cursor: pointer;
|
| 333 |
text-shadow: 2px 2px 0 #000;
|
| 334 |
+
box-shadow:
|
| 335 |
0 6px 0 #025a7c,
|
| 336 |
0 8px 20px rgba(0, 0, 0, 0.4);
|
| 337 |
transition: all 0.1s ease;
|
|
|
|
| 340 |
|
| 341 |
.restart-button:hover {
|
| 342 |
transform: translateY(-2px);
|
| 343 |
+
box-shadow:
|
| 344 |
0 8px 0 #025a7c,
|
| 345 |
0 10px 25px rgba(0, 0, 0, 0.5);
|
| 346 |
}
|
| 347 |
|
| 348 |
.restart-button:active {
|
| 349 |
transform: translateY(4px);
|
| 350 |
+
box-shadow:
|
| 351 |
0 2px 0 #025a7c,
|
| 352 |
0 4px 10px rgba(0, 0, 0, 0.4);
|
| 353 |
}
|
| 354 |
|
| 355 |
/* Responsive */
|
| 356 |
@media (max-width: 850px) {
|
| 357 |
+
|
| 358 |
.game-canvas,
|
| 359 |
.start-screen,
|
| 360 |
.game-over-screen {
|
|
|
|
| 363 |
height: auto;
|
| 364 |
aspect-ratio: 800 / 480;
|
| 365 |
}
|
| 366 |
+
|
| 367 |
.game-title {
|
| 368 |
font-size: 32px;
|
| 369 |
}
|
| 370 |
+
|
| 371 |
.game-title.mario {
|
| 372 |
font-size: 40px;
|
| 373 |
}
|
| 374 |
+
}
|
app/page.tsx
CHANGED
|
@@ -17,8 +17,10 @@ export default function Home() {
|
|
| 17 |
const [currentScreen, setCurrentScreen] = useState<GameScreen>('start');
|
| 18 |
const [isWin, setIsWin] = useState(false);
|
| 19 |
const [finalScore, setFinalScore] = useState(0);
|
|
|
|
| 20 |
|
| 21 |
-
const handleStart = useCallback(() => {
|
|
|
|
| 22 |
setCurrentScreen('playing');
|
| 23 |
}, []);
|
| 24 |
|
|
@@ -39,7 +41,11 @@ export default function Home() {
|
|
| 39 |
)}
|
| 40 |
|
| 41 |
{currentScreen === 'playing' && (
|
| 42 |
-
<GameCanvas
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
)}
|
| 44 |
|
| 45 |
{currentScreen === 'gameOver' && (
|
|
|
|
| 17 |
const [currentScreen, setCurrentScreen] = useState<GameScreen>('start');
|
| 18 |
const [isWin, setIsWin] = useState(false);
|
| 19 |
const [finalScore, setFinalScore] = useState(0);
|
| 20 |
+
const [startLevel, setStartLevel] = useState(0);
|
| 21 |
|
| 22 |
+
const handleStart = useCallback((levelIndex: number = 0) => {
|
| 23 |
+
setStartLevel(levelIndex);
|
| 24 |
setCurrentScreen('playing');
|
| 25 |
}, []);
|
| 26 |
|
|
|
|
| 41 |
)}
|
| 42 |
|
| 43 |
{currentScreen === 'playing' && (
|
| 44 |
+
<GameCanvas
|
| 45 |
+
key={startLevel}
|
| 46 |
+
onGameOver={handleGameOver}
|
| 47 |
+
startLevel={startLevel}
|
| 48 |
+
/>
|
| 49 |
)}
|
| 50 |
|
| 51 |
{currentScreen === 'gameOver' && (
|
lib/constants.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const GAME_CONFIG = {
|
|
| 40 |
} as const;
|
| 41 |
|
| 42 |
export const KEYS = {
|
| 43 |
-
LEFT: ['ArrowLeft', 'KeyA'],
|
| 44 |
-
RIGHT: ['ArrowRight', 'KeyD'],
|
| 45 |
-
JUMP: ['ArrowUp', 'KeyW', 'Space'],
|
| 46 |
-
}
|
|
|
|
| 40 |
} as const;
|
| 41 |
|
| 42 |
export const KEYS = {
|
| 43 |
+
LEFT: ['ArrowLeft', 'KeyA'] as string[],
|
| 44 |
+
RIGHT: ['ArrowRight', 'KeyD'] as string[],
|
| 45 |
+
JUMP: ['ArrowUp', 'KeyW', 'Space'] as string[],
|
| 46 |
+
};
|
lib/engine/AudioManager.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Audio Manager - handles all game sounds and music
|
| 2 |
+
|
| 3 |
+
export type SoundEffect = 'jump' | 'coin' | 'stomp' | 'death' | 'levelup' | 'gameover';
|
| 4 |
+
|
| 5 |
+
export class AudioManager {
|
| 6 |
+
private sounds: Map<string, HTMLAudioElement> = new Map();
|
| 7 |
+
private bgm: HTMLAudioElement | null = null;
|
| 8 |
+
private isMuted: boolean = false;
|
| 9 |
+
private volume: number = 0.5;
|
| 10 |
+
private initialized: boolean = false;
|
| 11 |
+
|
| 12 |
+
constructor() {
|
| 13 |
+
if (typeof window !== 'undefined') {
|
| 14 |
+
this.initSounds();
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
private initSounds(): void {
|
| 19 |
+
// Create audio elements with Web Audio API fallback
|
| 20 |
+
const soundEffects: SoundEffect[] = ['jump', 'coin', 'stomp', 'death', 'levelup', 'gameover'];
|
| 21 |
+
|
| 22 |
+
soundEffects.forEach(sound => {
|
| 23 |
+
const audio = new Audio();
|
| 24 |
+
audio.volume = this.volume;
|
| 25 |
+
// Use data URLs for simple 8-bit sounds
|
| 26 |
+
audio.src = this.getDataUrl(sound);
|
| 27 |
+
audio.preload = 'auto';
|
| 28 |
+
this.sounds.set(sound, audio);
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
// Background music
|
| 32 |
+
this.bgm = new Audio();
|
| 33 |
+
this.bgm.volume = this.volume * 0.3;
|
| 34 |
+
this.bgm.loop = true;
|
| 35 |
+
|
| 36 |
+
this.initialized = true;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
private getDataUrl(sound: SoundEffect): string {
|
| 40 |
+
// Generate simple 8-bit style sounds using oscillator tones encoded as data URLs
|
| 41 |
+
// These are placeholder audio - in production, use actual audio files
|
| 42 |
+
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
| 43 |
+
|
| 44 |
+
const frequencies: Record<SoundEffect, { freq: number; duration: number; type: OscillatorType }> = {
|
| 45 |
+
jump: { freq: 400, duration: 0.15, type: 'square' },
|
| 46 |
+
coin: { freq: 800, duration: 0.1, type: 'square' },
|
| 47 |
+
stomp: { freq: 200, duration: 0.1, type: 'square' },
|
| 48 |
+
death: { freq: 150, duration: 0.5, type: 'sawtooth' },
|
| 49 |
+
levelup: { freq: 600, duration: 0.3, type: 'square' },
|
| 50 |
+
gameover: { freq: 100, duration: 0.8, type: 'sawtooth' },
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// Store context for later use
|
| 54 |
+
this.audioContext = ctx;
|
| 55 |
+
this.soundConfigs = frequencies;
|
| 56 |
+
|
| 57 |
+
// Return empty data URL - we'll use Web Audio API directly
|
| 58 |
+
return 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
private audioContext: AudioContext | null = null;
|
| 62 |
+
private soundConfigs: Record<SoundEffect, { freq: number; duration: number; type: OscillatorType }> | null = null;
|
| 63 |
+
|
| 64 |
+
play(sound: SoundEffect): void {
|
| 65 |
+
if (this.isMuted || !this.initialized) return;
|
| 66 |
+
|
| 67 |
+
// Use Web Audio API for dynamic sound generation
|
| 68 |
+
if (this.audioContext && this.soundConfigs) {
|
| 69 |
+
try {
|
| 70 |
+
const config = this.soundConfigs[sound];
|
| 71 |
+
const oscillator = this.audioContext.createOscillator();
|
| 72 |
+
const gainNode = this.audioContext.createGain();
|
| 73 |
+
|
| 74 |
+
oscillator.type = config.type;
|
| 75 |
+
oscillator.frequency.setValueAtTime(config.freq, this.audioContext.currentTime);
|
| 76 |
+
|
| 77 |
+
// Frequency slide for jump sound
|
| 78 |
+
if (sound === 'jump') {
|
| 79 |
+
oscillator.frequency.exponentialRampToValueAtTime(
|
| 80 |
+
config.freq * 2,
|
| 81 |
+
this.audioContext.currentTime + config.duration
|
| 82 |
+
);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Frequency drop for death sound
|
| 86 |
+
if (sound === 'death' || sound === 'gameover') {
|
| 87 |
+
oscillator.frequency.exponentialRampToValueAtTime(
|
| 88 |
+
config.freq * 0.5,
|
| 89 |
+
this.audioContext.currentTime + config.duration
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Coin sound - quick double beep
|
| 94 |
+
if (sound === 'coin') {
|
| 95 |
+
oscillator.frequency.setValueAtTime(config.freq, this.audioContext.currentTime);
|
| 96 |
+
oscillator.frequency.setValueAtTime(config.freq * 1.5, this.audioContext.currentTime + 0.05);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
gainNode.gain.setValueAtTime(this.volume * 0.3, this.audioContext.currentTime);
|
| 100 |
+
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + config.duration);
|
| 101 |
+
|
| 102 |
+
oscillator.connect(gainNode);
|
| 103 |
+
gainNode.connect(this.audioContext.destination);
|
| 104 |
+
|
| 105 |
+
oscillator.start(this.audioContext.currentTime);
|
| 106 |
+
oscillator.stop(this.audioContext.currentTime + config.duration);
|
| 107 |
+
} catch (e) {
|
| 108 |
+
console.warn('Audio playback failed:', e);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
playBGM(): void {
|
| 114 |
+
if (this.isMuted || !this.bgm) return;
|
| 115 |
+
|
| 116 |
+
// Create a simple looping melody using Web Audio API
|
| 117 |
+
if (this.audioContext) {
|
| 118 |
+
this.createBGMLoop();
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
private bgmInterval: ReturnType<typeof setInterval> | null = null;
|
| 123 |
+
|
| 124 |
+
private createBGMLoop(): void {
|
| 125 |
+
if (!this.audioContext || this.bgmInterval) return;
|
| 126 |
+
|
| 127 |
+
const notes = [262, 294, 330, 349, 392, 440, 494, 523]; // C major scale
|
| 128 |
+
let noteIndex = 0;
|
| 129 |
+
|
| 130 |
+
this.bgmInterval = setInterval(() => {
|
| 131 |
+
if (this.isMuted || !this.audioContext) {
|
| 132 |
+
if (this.bgmInterval) clearInterval(this.bgmInterval);
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const oscillator = this.audioContext.createOscillator();
|
| 137 |
+
const gainNode = this.audioContext.createGain();
|
| 138 |
+
|
| 139 |
+
oscillator.type = 'square';
|
| 140 |
+
oscillator.frequency.setValueAtTime(notes[noteIndex % notes.length], this.audioContext.currentTime);
|
| 141 |
+
|
| 142 |
+
gainNode.gain.setValueAtTime(this.volume * 0.1, this.audioContext.currentTime);
|
| 143 |
+
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioContext.currentTime + 0.15);
|
| 144 |
+
|
| 145 |
+
oscillator.connect(gainNode);
|
| 146 |
+
gainNode.connect(this.audioContext.destination);
|
| 147 |
+
|
| 148 |
+
oscillator.start(this.audioContext.currentTime);
|
| 149 |
+
oscillator.stop(this.audioContext.currentTime + 0.15);
|
| 150 |
+
|
| 151 |
+
noteIndex++;
|
| 152 |
+
}, 200);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
stopBGM(): void {
|
| 156 |
+
if (this.bgmInterval) {
|
| 157 |
+
clearInterval(this.bgmInterval);
|
| 158 |
+
this.bgmInterval = null;
|
| 159 |
+
}
|
| 160 |
+
if (this.bgm) {
|
| 161 |
+
this.bgm.pause();
|
| 162 |
+
this.bgm.currentTime = 0;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
setVolume(volume: number): void {
|
| 167 |
+
this.volume = Math.max(0, Math.min(1, volume));
|
| 168 |
+
|
| 169 |
+
this.sounds.forEach(audio => {
|
| 170 |
+
audio.volume = this.volume;
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
if (this.bgm) {
|
| 174 |
+
this.bgm.volume = this.volume * 0.3;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
getVolume(): number {
|
| 179 |
+
return this.volume;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
toggleMute(): boolean {
|
| 183 |
+
this.isMuted = !this.isMuted;
|
| 184 |
+
|
| 185 |
+
if (this.isMuted) {
|
| 186 |
+
this.stopBGM();
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
return this.isMuted;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
isSoundMuted(): boolean {
|
| 193 |
+
return this.isMuted;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
setMuted(muted: boolean): void {
|
| 197 |
+
this.isMuted = muted;
|
| 198 |
+
if (muted) {
|
| 199 |
+
this.stopBGM();
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Resume audio context (needed after user interaction)
|
| 204 |
+
resume(): void {
|
| 205 |
+
if (this.audioContext && this.audioContext.state === 'suspended') {
|
| 206 |
+
this.audioContext.resume();
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Singleton instance
|
| 212 |
+
let audioManagerInstance: AudioManager | null = null;
|
| 213 |
+
|
| 214 |
+
export function getAudioManager(): AudioManager {
|
| 215 |
+
if (!audioManagerInstance) {
|
| 216 |
+
audioManagerInstance = new AudioManager();
|
| 217 |
+
}
|
| 218 |
+
return audioManagerInstance;
|
| 219 |
+
}
|
lib/engine/Game.ts
CHANGED
|
@@ -4,36 +4,53 @@ import { GameState, KeyState, PlayerState, Enemy, Block } from './types';
|
|
| 4 |
import { Renderer } from './Renderer';
|
| 5 |
import { Physics } from './Physics';
|
| 6 |
import { Collision } from './Collision';
|
| 7 |
-
import {
|
|
|
|
| 8 |
import { GAME_CONFIG, KEYS } from '../constants';
|
| 9 |
|
| 10 |
const { CANVAS_WIDTH, CANVAS_HEIGHT, LEVEL_HEIGHT, PLAYER_WIDTH, PLAYER_HEIGHT } = GAME_CONFIG;
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export class Game {
|
| 13 |
private canvas: HTMLCanvasElement;
|
| 14 |
private ctx: CanvasRenderingContext2D;
|
| 15 |
private renderer: Renderer;
|
| 16 |
private physics: Physics;
|
| 17 |
private collision: Collision;
|
|
|
|
| 18 |
private state: GameState;
|
| 19 |
private keys: KeyState;
|
| 20 |
private animationId: number | null = null;
|
| 21 |
-
private
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
constructor(canvas: HTMLCanvasElement) {
|
| 24 |
this.canvas = canvas;
|
| 25 |
this.ctx = canvas.getContext('2d')!;
|
| 26 |
this.renderer = new Renderer(this.ctx);
|
| 27 |
this.physics = new Physics();
|
| 28 |
this.collision = new Collision();
|
|
|
|
| 29 |
this.keys = { left: false, right: false, jump: false };
|
| 30 |
-
this.state = this.createInitialState();
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
this.setupEventListeners();
|
| 33 |
}
|
| 34 |
|
| 35 |
private createInitialState(): GameState {
|
| 36 |
-
const level =
|
| 37 |
|
| 38 |
return {
|
| 39 |
player: {
|
|
@@ -47,7 +64,7 @@ export class Game {
|
|
| 47 |
isFalling: true,
|
| 48 |
facingRight: true,
|
| 49 |
isAlive: true,
|
| 50 |
-
score:
|
| 51 |
coins: 0,
|
| 52 |
},
|
| 53 |
enemies: level.enemies.map(e => ({ ...e })),
|
|
@@ -73,6 +90,10 @@ export class Game {
|
|
| 73 |
if (KEYS.JUMP.includes(e.code)) {
|
| 74 |
this.keys.jump = true;
|
| 75 |
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
};
|
| 78 |
|
|
@@ -125,6 +146,7 @@ export class Game {
|
|
| 125 |
hitBlock.hasCoin = false;
|
| 126 |
this.state.player.coins++;
|
| 127 |
this.state.player.score += 100;
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
// Check coin collection
|
|
@@ -135,6 +157,7 @@ export class Game {
|
|
| 135 |
this.state.blocks.splice(index, 1);
|
| 136 |
this.state.player.coins++;
|
| 137 |
this.state.player.score += 50;
|
|
|
|
| 138 |
}
|
| 139 |
});
|
| 140 |
|
|
@@ -154,6 +177,7 @@ export class Game {
|
|
| 154 |
enemy.isAlive = false;
|
| 155 |
this.state.player.vy = -8; // Bounce off enemy
|
| 156 |
this.state.player.score += 200;
|
|
|
|
| 157 |
} else if (playerHit) {
|
| 158 |
this.gameOver(false);
|
| 159 |
}
|
|
@@ -162,7 +186,7 @@ export class Game {
|
|
| 162 |
|
| 163 |
// Check flag collision (win)
|
| 164 |
if (this.collision.checkFlagCollision(this.state.player, this.state.level.flagPosition)) {
|
| 165 |
-
this.
|
| 166 |
}
|
| 167 |
|
| 168 |
// Check fall death
|
|
@@ -175,7 +199,7 @@ export class Game {
|
|
| 175 |
}
|
| 176 |
|
| 177 |
private render(): void {
|
| 178 |
-
this.renderer.render(this.state);
|
| 179 |
}
|
| 180 |
|
| 181 |
private gameLoop = (): void => {
|
|
@@ -187,26 +211,66 @@ export class Game {
|
|
| 187 |
}
|
| 188 |
};
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
private gameOver(isWin: boolean): void {
|
| 191 |
this.state.isGameOver = true;
|
| 192 |
this.state.isWin = isWin;
|
| 193 |
-
this.state.player.isAlive =
|
| 194 |
|
| 195 |
-
|
| 196 |
-
this.state.player.score += 1000; // Bonus for completing level
|
| 197 |
-
}
|
| 198 |
|
| 199 |
// Render final frame
|
| 200 |
this.render();
|
| 201 |
|
| 202 |
-
if (this.onGameOver) {
|
| 203 |
setTimeout(() => {
|
| 204 |
-
this.onGameOver!(
|
| 205 |
-
},
|
| 206 |
}
|
| 207 |
}
|
| 208 |
|
| 209 |
start(): void {
|
|
|
|
| 210 |
this.state = this.createInitialState();
|
| 211 |
this.state.isRunning = true;
|
| 212 |
this.gameLoop();
|
|
@@ -218,19 +282,36 @@ export class Game {
|
|
| 218 |
cancelAnimationFrame(this.animationId);
|
| 219 |
this.animationId = null;
|
| 220 |
}
|
|
|
|
| 221 |
}
|
| 222 |
|
| 223 |
reset(): void {
|
| 224 |
this.stop();
|
|
|
|
|
|
|
|
|
|
| 225 |
this.state = this.createInitialState();
|
| 226 |
this.render();
|
| 227 |
}
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
setOnGameOver(callback: (isWin: boolean, score: number) => void): void {
|
| 230 |
-
this.onGameOver = callback;
|
| 231 |
}
|
| 232 |
|
| 233 |
getState(): GameState {
|
| 234 |
return this.state;
|
| 235 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
|
|
|
| 4 |
import { Renderer } from './Renderer';
|
| 5 |
import { Physics } from './Physics';
|
| 6 |
import { Collision } from './Collision';
|
| 7 |
+
import { LevelManager, LevelTheme } from './LevelManager';
|
| 8 |
+
import { getAudioManager, SoundEffect } from './AudioManager';
|
| 9 |
import { GAME_CONFIG, KEYS } from '../constants';
|
| 10 |
|
| 11 |
const { CANVAS_WIDTH, CANVAS_HEIGHT, LEVEL_HEIGHT, PLAYER_WIDTH, PLAYER_HEIGHT } = GAME_CONFIG;
|
| 12 |
|
| 13 |
+
export interface GameCallbacks {
|
| 14 |
+
onGameOver?: (isWin: boolean, score: number) => void;
|
| 15 |
+
onLevelComplete?: (levelNumber: number, nextLevel: number | null) => void;
|
| 16 |
+
onGameComplete?: (totalScore: number) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
export class Game {
|
| 20 |
private canvas: HTMLCanvasElement;
|
| 21 |
private ctx: CanvasRenderingContext2D;
|
| 22 |
private renderer: Renderer;
|
| 23 |
private physics: Physics;
|
| 24 |
private collision: Collision;
|
| 25 |
+
private levelManager: LevelManager;
|
| 26 |
private state: GameState;
|
| 27 |
private keys: KeyState;
|
| 28 |
private animationId: number | null = null;
|
| 29 |
+
private callbacks: GameCallbacks = {};
|
| 30 |
+
private totalScore: number = 0;
|
| 31 |
+
private currentTheme: LevelTheme = 'overworld';
|
| 32 |
|
| 33 |
+
constructor(canvas: HTMLCanvasElement, startLevel: number = 0) {
|
| 34 |
this.canvas = canvas;
|
| 35 |
this.ctx = canvas.getContext('2d')!;
|
| 36 |
this.renderer = new Renderer(this.ctx);
|
| 37 |
this.physics = new Physics();
|
| 38 |
this.collision = new Collision();
|
| 39 |
+
this.levelManager = new LevelManager();
|
| 40 |
this.keys = { left: false, right: false, jump: false };
|
|
|
|
| 41 |
|
| 42 |
+
// Set starting level
|
| 43 |
+
if (startLevel > 0) {
|
| 44 |
+
this.levelManager.setLevel(startLevel);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
this.state = this.createInitialState();
|
| 48 |
+
this.currentTheme = this.levelManager.getCurrentLevelInfo().theme;
|
| 49 |
this.setupEventListeners();
|
| 50 |
}
|
| 51 |
|
| 52 |
private createInitialState(): GameState {
|
| 53 |
+
const level = this.levelManager.getCurrentLevel();
|
| 54 |
|
| 55 |
return {
|
| 56 |
player: {
|
|
|
|
| 64 |
isFalling: true,
|
| 65 |
facingRight: true,
|
| 66 |
isAlive: true,
|
| 67 |
+
score: this.totalScore,
|
| 68 |
coins: 0,
|
| 69 |
},
|
| 70 |
enemies: level.enemies.map(e => ({ ...e })),
|
|
|
|
| 90 |
if (KEYS.JUMP.includes(e.code)) {
|
| 91 |
this.keys.jump = true;
|
| 92 |
e.preventDefault();
|
| 93 |
+
// Play jump sound
|
| 94 |
+
if (this.state.isRunning && !this.state.player.isJumping && !this.state.player.isFalling) {
|
| 95 |
+
getAudioManager().play('jump');
|
| 96 |
+
}
|
| 97 |
}
|
| 98 |
};
|
| 99 |
|
|
|
|
| 146 |
hitBlock.hasCoin = false;
|
| 147 |
this.state.player.coins++;
|
| 148 |
this.state.player.score += 100;
|
| 149 |
+
getAudioManager().play('coin');
|
| 150 |
}
|
| 151 |
|
| 152 |
// Check coin collection
|
|
|
|
| 157 |
this.state.blocks.splice(index, 1);
|
| 158 |
this.state.player.coins++;
|
| 159 |
this.state.player.score += 50;
|
| 160 |
+
getAudioManager().play('coin');
|
| 161 |
}
|
| 162 |
});
|
| 163 |
|
|
|
|
| 177 |
enemy.isAlive = false;
|
| 178 |
this.state.player.vy = -8; // Bounce off enemy
|
| 179 |
this.state.player.score += 200;
|
| 180 |
+
getAudioManager().play('stomp');
|
| 181 |
} else if (playerHit) {
|
| 182 |
this.gameOver(false);
|
| 183 |
}
|
|
|
|
| 186 |
|
| 187 |
// Check flag collision (win)
|
| 188 |
if (this.collision.checkFlagCollision(this.state.player, this.state.level.flagPosition)) {
|
| 189 |
+
this.levelComplete();
|
| 190 |
}
|
| 191 |
|
| 192 |
// Check fall death
|
|
|
|
| 199 |
}
|
| 200 |
|
| 201 |
private render(): void {
|
| 202 |
+
this.renderer.render(this.state, this.currentTheme, this.levelManager.getCurrentLevelNumber());
|
| 203 |
}
|
| 204 |
|
| 205 |
private gameLoop = (): void => {
|
|
|
|
| 211 |
}
|
| 212 |
};
|
| 213 |
|
| 214 |
+
private levelComplete(): void {
|
| 215 |
+
this.state.isGameOver = true;
|
| 216 |
+
this.state.isWin = true;
|
| 217 |
+
this.state.player.score += 1000; // Level completion bonus
|
| 218 |
+
this.totalScore = this.state.player.score;
|
| 219 |
+
|
| 220 |
+
getAudioManager().play('levelup');
|
| 221 |
+
|
| 222 |
+
// Render final frame
|
| 223 |
+
this.render();
|
| 224 |
+
|
| 225 |
+
const currentLevelNum = this.levelManager.getCurrentLevelNumber();
|
| 226 |
+
|
| 227 |
+
// Check if there are more levels
|
| 228 |
+
if (!this.levelManager.isLastLevel()) {
|
| 229 |
+
// Proceed to next level after delay
|
| 230 |
+
setTimeout(() => {
|
| 231 |
+
const nextLevel = this.levelManager.nextLevel();
|
| 232 |
+
if (nextLevel) {
|
| 233 |
+
this.currentTheme = this.levelManager.getCurrentLevelInfo().theme;
|
| 234 |
+
this.state = this.createInitialState();
|
| 235 |
+
this.state.isRunning = true;
|
| 236 |
+
this.gameLoop();
|
| 237 |
+
|
| 238 |
+
if (this.callbacks.onLevelComplete) {
|
| 239 |
+
this.callbacks.onLevelComplete(currentLevelNum, this.levelManager.getCurrentLevelNumber());
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
}, 2000);
|
| 243 |
+
} else {
|
| 244 |
+
// Game complete!
|
| 245 |
+
setTimeout(() => {
|
| 246 |
+
if (this.callbacks.onGameComplete) {
|
| 247 |
+
this.callbacks.onGameComplete(this.totalScore);
|
| 248 |
+
} else if (this.callbacks.onGameOver) {
|
| 249 |
+
this.callbacks.onGameOver(true, this.totalScore);
|
| 250 |
+
}
|
| 251 |
+
}, 2000);
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
private gameOver(isWin: boolean): void {
|
| 256 |
this.state.isGameOver = true;
|
| 257 |
this.state.isWin = isWin;
|
| 258 |
+
this.state.player.isAlive = false;
|
| 259 |
|
| 260 |
+
getAudioManager().play('death');
|
|
|
|
|
|
|
| 261 |
|
| 262 |
// Render final frame
|
| 263 |
this.render();
|
| 264 |
|
| 265 |
+
if (this.callbacks.onGameOver) {
|
| 266 |
setTimeout(() => {
|
| 267 |
+
this.callbacks.onGameOver!(false, this.state.player.score);
|
| 268 |
+
}, 1500);
|
| 269 |
}
|
| 270 |
}
|
| 271 |
|
| 272 |
start(): void {
|
| 273 |
+
getAudioManager().resume();
|
| 274 |
this.state = this.createInitialState();
|
| 275 |
this.state.isRunning = true;
|
| 276 |
this.gameLoop();
|
|
|
|
| 282 |
cancelAnimationFrame(this.animationId);
|
| 283 |
this.animationId = null;
|
| 284 |
}
|
| 285 |
+
getAudioManager().stopBGM();
|
| 286 |
}
|
| 287 |
|
| 288 |
reset(): void {
|
| 289 |
this.stop();
|
| 290 |
+
this.totalScore = 0;
|
| 291 |
+
this.levelManager.resetToFirstLevel();
|
| 292 |
+
this.currentTheme = this.levelManager.getCurrentLevelInfo().theme;
|
| 293 |
this.state = this.createInitialState();
|
| 294 |
this.render();
|
| 295 |
}
|
| 296 |
|
| 297 |
+
setCallbacks(callbacks: GameCallbacks): void {
|
| 298 |
+
this.callbacks = callbacks;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Legacy support
|
| 302 |
setOnGameOver(callback: (isWin: boolean, score: number) => void): void {
|
| 303 |
+
this.callbacks.onGameOver = callback;
|
| 304 |
}
|
| 305 |
|
| 306 |
getState(): GameState {
|
| 307 |
return this.state;
|
| 308 |
}
|
| 309 |
+
|
| 310 |
+
getCurrentLevel(): number {
|
| 311 |
+
return this.levelManager.getCurrentLevelNumber();
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
getTotalLevels(): number {
|
| 315 |
+
return this.levelManager.getLevelCount();
|
| 316 |
+
}
|
| 317 |
}
|
lib/engine/Level2.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Level 2 - Underground Cave Theme
|
| 2 |
+
|
| 3 |
+
import { Level, Block, Enemy } from './types';
|
| 4 |
+
import { GAME_CONFIG } from '../constants';
|
| 5 |
+
|
| 6 |
+
const { TILE_SIZE, LEVEL_HEIGHT } = GAME_CONFIG;
|
| 7 |
+
|
| 8 |
+
const LEVEL_2_WIDTH = 3600;
|
| 9 |
+
|
| 10 |
+
export function createLevel2(): Level {
|
| 11 |
+
const blocks: Block[] = [];
|
| 12 |
+
const enemies: Enemy[] = [];
|
| 13 |
+
|
| 14 |
+
// Ground blocks with more gaps
|
| 15 |
+
for (let x = 0; x < LEVEL_2_WIDTH; x += TILE_SIZE) {
|
| 16 |
+
// Multiple gaps for underground feel
|
| 17 |
+
const gaps = [
|
| 18 |
+
{ start: 800, end: 900 },
|
| 19 |
+
{ start: 1400, end: 1550 },
|
| 20 |
+
{ start: 2200, end: 2350 },
|
| 21 |
+
{ start: 2800, end: 2900 },
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
const inGap = gaps.some(gap => x >= gap.start && x < gap.end);
|
| 25 |
+
if (inGap) continue;
|
| 26 |
+
|
| 27 |
+
// Ground layer (2 blocks high)
|
| 28 |
+
blocks.push({
|
| 29 |
+
x,
|
| 30 |
+
y: LEVEL_HEIGHT - TILE_SIZE,
|
| 31 |
+
width: TILE_SIZE,
|
| 32 |
+
height: TILE_SIZE,
|
| 33 |
+
type: 'ground',
|
| 34 |
+
});
|
| 35 |
+
blocks.push({
|
| 36 |
+
x,
|
| 37 |
+
y: LEVEL_HEIGHT - TILE_SIZE * 2,
|
| 38 |
+
width: TILE_SIZE,
|
| 39 |
+
height: TILE_SIZE,
|
| 40 |
+
type: 'ground',
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Ceiling blocks for underground feel
|
| 45 |
+
for (let x = 0; x < LEVEL_2_WIDTH; x += TILE_SIZE) {
|
| 46 |
+
blocks.push({
|
| 47 |
+
x,
|
| 48 |
+
y: 0,
|
| 49 |
+
width: TILE_SIZE,
|
| 50 |
+
height: TILE_SIZE,
|
| 51 |
+
type: 'ground',
|
| 52 |
+
});
|
| 53 |
+
blocks.push({
|
| 54 |
+
x,
|
| 55 |
+
y: TILE_SIZE,
|
| 56 |
+
width: TILE_SIZE,
|
| 57 |
+
height: TILE_SIZE,
|
| 58 |
+
type: 'ground',
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Underground platforms - brick style
|
| 63 |
+
const platforms = [
|
| 64 |
+
// First section - stepping stones
|
| 65 |
+
{ x: 200, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 66 |
+
{ x: 280, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 67 |
+
{ x: 360, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 68 |
+
|
| 69 |
+
// Question blocks with coins
|
| 70 |
+
{ x: 500, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 71 |
+
{ x: 600, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 72 |
+
|
| 73 |
+
// Long platform section
|
| 74 |
+
{ x: 700, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 75 |
+
{ x: 732, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 76 |
+
{ x: 764, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 77 |
+
|
| 78 |
+
// After first gap
|
| 79 |
+
{ x: 950, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 80 |
+
{ x: 1000, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'question' as const, hasCoin: true },
|
| 81 |
+
{ x: 1050, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 82 |
+
|
| 83 |
+
// Mid-level platforms
|
| 84 |
+
{ x: 1200, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 85 |
+
{ x: 1232, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 86 |
+
{ x: 1264, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 87 |
+
{ x: 1296, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 88 |
+
|
| 89 |
+
// High platform section
|
| 90 |
+
{ x: 1600, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'brick' as const },
|
| 91 |
+
{ x: 1632, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'brick' as const },
|
| 92 |
+
{ x: 1664, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'question' as const, hasCoin: true },
|
| 93 |
+
{ x: 1696, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'brick' as const },
|
| 94 |
+
|
| 95 |
+
// Low platforms
|
| 96 |
+
{ x: 1800, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 97 |
+
{ x: 1900, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 98 |
+
{ x: 2000, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 99 |
+
|
| 100 |
+
// After gaps section
|
| 101 |
+
{ x: 2400, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 102 |
+
{ x: 2500, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 103 |
+
{ x: 2600, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 104 |
+
|
| 105 |
+
// Final staircase
|
| 106 |
+
{ x: 3000, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 107 |
+
{ x: 3032, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 108 |
+
{ x: 3032, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 109 |
+
{ x: 3064, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 110 |
+
{ x: 3064, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 111 |
+
{ x: 3064, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'ground' as const },
|
| 112 |
+
{ x: 3096, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 113 |
+
{ x: 3096, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 114 |
+
{ x: 3096, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'ground' as const },
|
| 115 |
+
{ x: 3096, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'ground' as const },
|
| 116 |
+
];
|
| 117 |
+
|
| 118 |
+
platforms.forEach(p => {
|
| 119 |
+
blocks.push({
|
| 120 |
+
x: p.x,
|
| 121 |
+
y: p.y,
|
| 122 |
+
width: TILE_SIZE,
|
| 123 |
+
height: TILE_SIZE,
|
| 124 |
+
type: p.type,
|
| 125 |
+
hasCoin: p.hasCoin,
|
| 126 |
+
});
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
// More pipes in underground
|
| 130 |
+
const pipes = [
|
| 131 |
+
{ x: 400, height: 2 },
|
| 132 |
+
{ x: 1100, height: 3 },
|
| 133 |
+
{ x: 1750, height: 2 },
|
| 134 |
+
{ x: 2100, height: 4 },
|
| 135 |
+
{ x: 2700, height: 2 },
|
| 136 |
+
];
|
| 137 |
+
|
| 138 |
+
pipes.forEach(pipe => {
|
| 139 |
+
for (let h = 0; h < pipe.height; h++) {
|
| 140 |
+
blocks.push({
|
| 141 |
+
x: pipe.x,
|
| 142 |
+
y: LEVEL_HEIGHT - TILE_SIZE * 2 - (h * TILE_SIZE),
|
| 143 |
+
width: TILE_SIZE * 2,
|
| 144 |
+
height: TILE_SIZE,
|
| 145 |
+
type: 'pipe',
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
// Floating coins
|
| 151 |
+
const coinPositions = [
|
| 152 |
+
{ x: 250, y: LEVEL_HEIGHT - TILE_SIZE * 7 },
|
| 153 |
+
{ x: 282, y: LEVEL_HEIGHT - TILE_SIZE * 8 },
|
| 154 |
+
{ x: 314, y: LEVEL_HEIGHT - TILE_SIZE * 7 },
|
| 155 |
+
{ x: 1650, y: LEVEL_HEIGHT - TILE_SIZE * 9 },
|
| 156 |
+
{ x: 2550, y: LEVEL_HEIGHT - TILE_SIZE * 8 },
|
| 157 |
+
];
|
| 158 |
+
|
| 159 |
+
coinPositions.forEach(pos => {
|
| 160 |
+
blocks.push({
|
| 161 |
+
x: pos.x,
|
| 162 |
+
y: pos.y,
|
| 163 |
+
width: 24,
|
| 164 |
+
height: 24,
|
| 165 |
+
type: 'coin',
|
| 166 |
+
});
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// More enemies - including Koopas
|
| 170 |
+
const enemyPositions = [
|
| 171 |
+
{ x: 350, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 172 |
+
{ x: 650, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 173 |
+
{ x: 750, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'goomba' as const },
|
| 174 |
+
{ x: 1050, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'koopa' as const },
|
| 175 |
+
{ x: 1350, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 176 |
+
{ x: 1650, y: LEVEL_HEIGHT - TILE_SIZE * 9, type: 'goomba' as const },
|
| 177 |
+
{ x: 1950, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'koopa' as const },
|
| 178 |
+
{ x: 2450, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 179 |
+
{ x: 2650, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 180 |
+
];
|
| 181 |
+
|
| 182 |
+
enemyPositions.forEach(pos => {
|
| 183 |
+
enemies.push({
|
| 184 |
+
x: pos.x,
|
| 185 |
+
y: pos.y,
|
| 186 |
+
vx: -1,
|
| 187 |
+
vy: 0,
|
| 188 |
+
width: 32,
|
| 189 |
+
height: pos.type === 'koopa' ? 40 : 32,
|
| 190 |
+
type: pos.type,
|
| 191 |
+
isAlive: true,
|
| 192 |
+
direction: -1,
|
| 193 |
+
});
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
width: LEVEL_2_WIDTH,
|
| 198 |
+
height: LEVEL_HEIGHT,
|
| 199 |
+
blocks,
|
| 200 |
+
enemies,
|
| 201 |
+
startPosition: { x: 64, y: LEVEL_HEIGHT - TILE_SIZE * 4 },
|
| 202 |
+
flagPosition: { x: 3200, y: LEVEL_HEIGHT - TILE_SIZE * 10 },
|
| 203 |
+
};
|
| 204 |
+
}
|
lib/engine/Level3.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Level 3 - Castle Theme
|
| 2 |
+
|
| 3 |
+
import { Level, Block, Enemy } from './types';
|
| 4 |
+
import { GAME_CONFIG } from '../constants';
|
| 5 |
+
|
| 6 |
+
const { TILE_SIZE, LEVEL_HEIGHT } = GAME_CONFIG;
|
| 7 |
+
|
| 8 |
+
const LEVEL_3_WIDTH = 4000;
|
| 9 |
+
|
| 10 |
+
export function createLevel3(): Level {
|
| 11 |
+
const blocks: Block[] = [];
|
| 12 |
+
const enemies: Enemy[] = [];
|
| 13 |
+
|
| 14 |
+
// Ground blocks with lava pits
|
| 15 |
+
for (let x = 0; x < LEVEL_3_WIDTH; x += TILE_SIZE) {
|
| 16 |
+
// Lava pit positions (deadly gaps)
|
| 17 |
+
const lavaPits = [
|
| 18 |
+
{ start: 600, end: 750 },
|
| 19 |
+
{ start: 1200, end: 1400 },
|
| 20 |
+
{ start: 1800, end: 1950 },
|
| 21 |
+
{ start: 2400, end: 2600 },
|
| 22 |
+
{ start: 3000, end: 3150 },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const inLava = lavaPits.some(pit => x >= pit.start && x < pit.end);
|
| 26 |
+
if (inLava) continue;
|
| 27 |
+
|
| 28 |
+
// Castle floor (darker ground)
|
| 29 |
+
blocks.push({
|
| 30 |
+
x,
|
| 31 |
+
y: LEVEL_HEIGHT - TILE_SIZE,
|
| 32 |
+
width: TILE_SIZE,
|
| 33 |
+
height: TILE_SIZE,
|
| 34 |
+
type: 'ground',
|
| 35 |
+
});
|
| 36 |
+
blocks.push({
|
| 37 |
+
x,
|
| 38 |
+
y: LEVEL_HEIGHT - TILE_SIZE * 2,
|
| 39 |
+
width: TILE_SIZE,
|
| 40 |
+
height: TILE_SIZE,
|
| 41 |
+
type: 'ground',
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Castle walls and platforms
|
| 46 |
+
const platforms = [
|
| 47 |
+
// Early section - stepping platforms
|
| 48 |
+
{ x: 200, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 49 |
+
{ x: 300, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 50 |
+
{ x: 400, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'question' as const, hasCoin: true },
|
| 51 |
+
{ x: 500, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 52 |
+
|
| 53 |
+
// Over first lava pit
|
| 54 |
+
{ x: 620, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 55 |
+
{ x: 680, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 56 |
+
{ x: 740, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 57 |
+
|
| 58 |
+
// Mid section
|
| 59 |
+
{ x: 850, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 60 |
+
{ x: 950, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 61 |
+
{ x: 1050, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 62 |
+
{ x: 1100, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 63 |
+
|
| 64 |
+
// Over second lava pit - tricky jumps
|
| 65 |
+
{ x: 1220, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 66 |
+
{ x: 1280, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'brick' as const },
|
| 67 |
+
{ x: 1340, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 68 |
+
|
| 69 |
+
// Question block cluster
|
| 70 |
+
{ x: 1500, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 71 |
+
{ x: 1532, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 72 |
+
{ x: 1564, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'question' as const, hasCoin: true },
|
| 73 |
+
|
| 74 |
+
// High platform section
|
| 75 |
+
{ x: 1650, y: LEVEL_HEIGHT - TILE_SIZE * 8, type: 'brick' as const },
|
| 76 |
+
{ x: 1682, y: LEVEL_HEIGHT - TILE_SIZE * 8, type: 'brick' as const },
|
| 77 |
+
{ x: 1714, y: LEVEL_HEIGHT - TILE_SIZE * 8, type: 'brick' as const },
|
| 78 |
+
|
| 79 |
+
// Over third lava pit
|
| 80 |
+
{ x: 1820, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 81 |
+
{ x: 1880, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 82 |
+
{ x: 1940, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 83 |
+
|
| 84 |
+
// Long platform run
|
| 85 |
+
{ x: 2050, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 86 |
+
{ x: 2082, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 87 |
+
{ x: 2114, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 88 |
+
{ x: 2146, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 89 |
+
{ x: 2178, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 90 |
+
{ x: 2210, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 91 |
+
{ x: 2242, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 92 |
+
{ x: 2274, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 93 |
+
|
| 94 |
+
// Over fourth lava pit
|
| 95 |
+
{ x: 2420, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 96 |
+
{ x: 2480, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'question' as const, hasCoin: true },
|
| 97 |
+
{ x: 2540, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 98 |
+
|
| 99 |
+
// Pre-boss section
|
| 100 |
+
{ x: 2700, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'brick' as const },
|
| 101 |
+
{ x: 2800, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 102 |
+
{ x: 2900, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 103 |
+
|
| 104 |
+
// Over fifth lava pit
|
| 105 |
+
{ x: 3020, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 106 |
+
{ x: 3080, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'brick' as const },
|
| 107 |
+
{ x: 3140, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'brick' as const },
|
| 108 |
+
|
| 109 |
+
// Final staircase to flag
|
| 110 |
+
{ x: 3300, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 111 |
+
{ x: 3332, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 112 |
+
{ x: 3332, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 113 |
+
{ x: 3364, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 114 |
+
{ x: 3364, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 115 |
+
{ x: 3364, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'ground' as const },
|
| 116 |
+
{ x: 3396, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 117 |
+
{ x: 3396, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 118 |
+
{ x: 3396, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'ground' as const },
|
| 119 |
+
{ x: 3396, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'ground' as const },
|
| 120 |
+
{ x: 3428, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'ground' as const },
|
| 121 |
+
{ x: 3428, y: LEVEL_HEIGHT - TILE_SIZE * 4, type: 'ground' as const },
|
| 122 |
+
{ x: 3428, y: LEVEL_HEIGHT - TILE_SIZE * 5, type: 'ground' as const },
|
| 123 |
+
{ x: 3428, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'ground' as const },
|
| 124 |
+
{ x: 3428, y: LEVEL_HEIGHT - TILE_SIZE * 7, type: 'ground' as const },
|
| 125 |
+
];
|
| 126 |
+
|
| 127 |
+
platforms.forEach(p => {
|
| 128 |
+
blocks.push({
|
| 129 |
+
x: p.x,
|
| 130 |
+
y: p.y,
|
| 131 |
+
width: TILE_SIZE,
|
| 132 |
+
height: TILE_SIZE,
|
| 133 |
+
type: p.type,
|
| 134 |
+
hasCoin: p.hasCoin,
|
| 135 |
+
});
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
// Fewer pipes in castle
|
| 139 |
+
const pipes = [
|
| 140 |
+
{ x: 800, height: 2 },
|
| 141 |
+
{ x: 2000, height: 2 },
|
| 142 |
+
{ x: 2650, height: 3 },
|
| 143 |
+
];
|
| 144 |
+
|
| 145 |
+
pipes.forEach(pipe => {
|
| 146 |
+
for (let h = 0; h < pipe.height; h++) {
|
| 147 |
+
blocks.push({
|
| 148 |
+
x: pipe.x,
|
| 149 |
+
y: LEVEL_HEIGHT - TILE_SIZE * 2 - (h * TILE_SIZE),
|
| 150 |
+
width: TILE_SIZE * 2,
|
| 151 |
+
height: TILE_SIZE,
|
| 152 |
+
type: 'pipe',
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
// Floating coins - harder to get
|
| 158 |
+
const coinPositions = [
|
| 159 |
+
{ x: 680, y: LEVEL_HEIGHT - TILE_SIZE * 8 },
|
| 160 |
+
{ x: 1280, y: LEVEL_HEIGHT - TILE_SIZE * 9 },
|
| 161 |
+
{ x: 1880, y: LEVEL_HEIGHT - TILE_SIZE * 8 },
|
| 162 |
+
{ x: 2480, y: LEVEL_HEIGHT - TILE_SIZE * 9 },
|
| 163 |
+
{ x: 3080, y: LEVEL_HEIGHT - TILE_SIZE * 8 },
|
| 164 |
+
];
|
| 165 |
+
|
| 166 |
+
coinPositions.forEach(pos => {
|
| 167 |
+
blocks.push({
|
| 168 |
+
x: pos.x,
|
| 169 |
+
y: pos.y,
|
| 170 |
+
width: 24,
|
| 171 |
+
height: 24,
|
| 172 |
+
type: 'coin',
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
// Many enemies - challenging!
|
| 177 |
+
const enemyPositions = [
|
| 178 |
+
{ x: 250, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 179 |
+
{ x: 450, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'koopa' as const },
|
| 180 |
+
{ x: 900, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 181 |
+
{ x: 1000, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 182 |
+
{ x: 1450, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'koopa' as const },
|
| 183 |
+
{ x: 1700, y: LEVEL_HEIGHT - TILE_SIZE * 10, type: 'goomba' as const },
|
| 184 |
+
{ x: 2100, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'goomba' as const },
|
| 185 |
+
{ x: 2200, y: LEVEL_HEIGHT - TILE_SIZE * 6, type: 'koopa' as const },
|
| 186 |
+
{ x: 2750, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 187 |
+
{ x: 2850, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'goomba' as const },
|
| 188 |
+
{ x: 3200, y: LEVEL_HEIGHT - TILE_SIZE * 3, type: 'koopa' as const },
|
| 189 |
+
];
|
| 190 |
+
|
| 191 |
+
enemyPositions.forEach(pos => {
|
| 192 |
+
enemies.push({
|
| 193 |
+
x: pos.x,
|
| 194 |
+
y: pos.y,
|
| 195 |
+
vx: -1,
|
| 196 |
+
vy: 0,
|
| 197 |
+
width: 32,
|
| 198 |
+
height: pos.type === 'koopa' ? 40 : 32,
|
| 199 |
+
type: pos.type,
|
| 200 |
+
isAlive: true,
|
| 201 |
+
direction: -1,
|
| 202 |
+
});
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
width: LEVEL_3_WIDTH,
|
| 207 |
+
height: LEVEL_HEIGHT,
|
| 208 |
+
blocks,
|
| 209 |
+
enemies,
|
| 210 |
+
startPosition: { x: 64, y: LEVEL_HEIGHT - TILE_SIZE * 4 },
|
| 211 |
+
flagPosition: { x: 3550, y: LEVEL_HEIGHT - TILE_SIZE * 10 },
|
| 212 |
+
};
|
| 213 |
+
}
|
lib/engine/LevelManager.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Level Manager - handles multiple levels and level transitions
|
| 2 |
+
|
| 3 |
+
import { Level } from './types';
|
| 4 |
+
import { createLevel1 } from './Level';
|
| 5 |
+
import { createLevel2 } from './Level2';
|
| 6 |
+
import { createLevel3 } from './Level3';
|
| 7 |
+
|
| 8 |
+
export type LevelTheme = 'overworld' | 'underground' | 'castle';
|
| 9 |
+
|
| 10 |
+
export interface LevelInfo {
|
| 11 |
+
id: number;
|
| 12 |
+
name: string;
|
| 13 |
+
theme: LevelTheme;
|
| 14 |
+
createLevel: () => Level;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export class LevelManager {
|
| 18 |
+
private levels: LevelInfo[] = [
|
| 19 |
+
{
|
| 20 |
+
id: 1,
|
| 21 |
+
name: 'World 1-1',
|
| 22 |
+
theme: 'overworld',
|
| 23 |
+
createLevel: createLevel1,
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: 2,
|
| 27 |
+
name: 'World 1-2',
|
| 28 |
+
theme: 'underground',
|
| 29 |
+
createLevel: createLevel2,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
id: 3,
|
| 33 |
+
name: 'World 1-3',
|
| 34 |
+
theme: 'castle',
|
| 35 |
+
createLevel: createLevel3,
|
| 36 |
+
},
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
private currentLevelIndex: number = 0;
|
| 40 |
+
|
| 41 |
+
getCurrentLevel(): Level {
|
| 42 |
+
return this.levels[this.currentLevelIndex].createLevel();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
getCurrentLevelInfo(): LevelInfo {
|
| 46 |
+
return this.levels[this.currentLevelIndex];
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
getLevelCount(): number {
|
| 50 |
+
return this.levels.length;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
getCurrentLevelNumber(): number {
|
| 54 |
+
return this.currentLevelIndex + 1;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
setLevel(levelIndex: number): Level | null {
|
| 58 |
+
if (levelIndex >= 0 && levelIndex < this.levels.length) {
|
| 59 |
+
this.currentLevelIndex = levelIndex;
|
| 60 |
+
return this.getCurrentLevel();
|
| 61 |
+
}
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
nextLevel(): Level | null {
|
| 66 |
+
if (this.currentLevelIndex < this.levels.length - 1) {
|
| 67 |
+
this.currentLevelIndex++;
|
| 68 |
+
return this.getCurrentLevel();
|
| 69 |
+
}
|
| 70 |
+
return null; // No more levels - game complete!
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
previousLevel(): Level | null {
|
| 74 |
+
if (this.currentLevelIndex > 0) {
|
| 75 |
+
this.currentLevelIndex--;
|
| 76 |
+
return this.getCurrentLevel();
|
| 77 |
+
}
|
| 78 |
+
return null;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
resetToFirstLevel(): Level {
|
| 82 |
+
this.currentLevelIndex = 0;
|
| 83 |
+
return this.getCurrentLevel();
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
isLastLevel(): boolean {
|
| 87 |
+
return this.currentLevelIndex === this.levels.length - 1;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
getAllLevels(): LevelInfo[] {
|
| 91 |
+
return [...this.levels];
|
| 92 |
+
}
|
| 93 |
+
}
|
lib/engine/Renderer.ts
CHANGED
|
@@ -2,9 +2,16 @@
|
|
| 2 |
|
| 3 |
import { GameState, Block, Enemy, PlayerState, Position } from './types';
|
| 4 |
import { GAME_CONFIG } from '../constants';
|
|
|
|
| 5 |
|
| 6 |
const { CANVAS_WIDTH, CANVAS_HEIGHT, TILE_SIZE, COLORS } = GAME_CONFIG;
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
export class Renderer {
|
| 9 |
private ctx: CanvasRenderingContext2D;
|
| 10 |
private animationFrame: number = 0;
|
|
@@ -13,9 +20,11 @@ export class Renderer {
|
|
| 13 |
this.ctx = ctx;
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
| 16 |
clear(): void {
|
| 17 |
-
// Draw sky background
|
| 18 |
-
this.ctx.fillStyle =
|
| 19 |
this.ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
| 20 |
}
|
| 21 |
|
|
@@ -248,6 +257,11 @@ export class Renderer {
|
|
| 248 |
|
| 249 |
if (!enemy.isAlive) return;
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
// Goomba body
|
| 252 |
this.ctx.fillStyle = COLORS.GOOMBA;
|
| 253 |
|
|
@@ -289,6 +303,44 @@ export class Renderer {
|
|
| 289 |
this.ctx.stroke();
|
| 290 |
}
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
drawFlag(position: Position, cameraX: number): void {
|
| 293 |
const screenX = position.x - cameraX;
|
| 294 |
|
|
@@ -321,10 +373,10 @@ export class Renderer {
|
|
| 321 |
this.ctx.fillText('★', screenX + 22 + flagWave / 2, position.y + 25);
|
| 322 |
}
|
| 323 |
|
| 324 |
-
drawUI(player: PlayerState): void {
|
| 325 |
// UI Background
|
| 326 |
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
| 327 |
-
this.ctx.fillRect(10, 10,
|
| 328 |
|
| 329 |
// Score
|
| 330 |
this.ctx.fillStyle = '#fff';
|
|
@@ -341,15 +393,25 @@ export class Renderer {
|
|
| 341 |
|
| 342 |
this.ctx.fillStyle = '#fff';
|
| 343 |
this.ctx.fillText(`×${player.coins}`, 165, 40);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
update(): void {
|
| 347 |
this.animationFrame++;
|
| 348 |
}
|
| 349 |
|
| 350 |
-
render(state: GameState): void {
|
|
|
|
| 351 |
this.clear();
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
// Draw all blocks
|
| 355 |
state.blocks.forEach(block => this.drawBlock(block, state.camera.x));
|
|
@@ -364,7 +426,7 @@ export class Renderer {
|
|
| 364 |
this.drawPlayer(state.player, state.camera.x);
|
| 365 |
|
| 366 |
// Draw UI
|
| 367 |
-
this.drawUI(state.player);
|
| 368 |
|
| 369 |
this.update();
|
| 370 |
}
|
|
|
|
| 2 |
|
| 3 |
import { GameState, Block, Enemy, PlayerState, Position } from './types';
|
| 4 |
import { GAME_CONFIG } from '../constants';
|
| 5 |
+
import { LevelTheme } from './LevelManager';
|
| 6 |
|
| 7 |
const { CANVAS_WIDTH, CANVAS_HEIGHT, TILE_SIZE, COLORS } = GAME_CONFIG;
|
| 8 |
|
| 9 |
+
const THEME_COLORS = {
|
| 10 |
+
overworld: { sky: COLORS.SKY, ground: COLORS.GROUND },
|
| 11 |
+
underground: { sky: '#1a1a2e', ground: '#4a4a4a' },
|
| 12 |
+
castle: { sky: '#2d1b1b', ground: '#5a3030' },
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
export class Renderer {
|
| 16 |
private ctx: CanvasRenderingContext2D;
|
| 17 |
private animationFrame: number = 0;
|
|
|
|
| 20 |
this.ctx = ctx;
|
| 21 |
}
|
| 22 |
|
| 23 |
+
private currentTheme: LevelTheme = 'overworld';
|
| 24 |
+
|
| 25 |
clear(): void {
|
| 26 |
+
// Draw sky background based on theme
|
| 27 |
+
this.ctx.fillStyle = THEME_COLORS[this.currentTheme].sky;
|
| 28 |
this.ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
| 29 |
}
|
| 30 |
|
|
|
|
| 257 |
|
| 258 |
if (!enemy.isAlive) return;
|
| 259 |
|
| 260 |
+
if (enemy.type === 'koopa') {
|
| 261 |
+
this.drawKoopa(screenX, enemy);
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
// Goomba body
|
| 266 |
this.ctx.fillStyle = COLORS.GOOMBA;
|
| 267 |
|
|
|
|
| 303 |
this.ctx.stroke();
|
| 304 |
}
|
| 305 |
|
| 306 |
+
private drawKoopa(screenX: number, enemy: Enemy): void {
|
| 307 |
+
// Shell (green)
|
| 308 |
+
this.ctx.fillStyle = '#00aa00';
|
| 309 |
+
this.ctx.beginPath();
|
| 310 |
+
this.ctx.ellipse(screenX + enemy.width / 2, enemy.y + enemy.height - 12, 14, 10, 0, 0, Math.PI * 2);
|
| 311 |
+
this.ctx.fill();
|
| 312 |
+
|
| 313 |
+
// Shell pattern
|
| 314 |
+
this.ctx.strokeStyle = '#006600';
|
| 315 |
+
this.ctx.lineWidth = 2;
|
| 316 |
+
this.ctx.beginPath();
|
| 317 |
+
this.ctx.arc(screenX + enemy.width / 2, enemy.y + enemy.height - 12, 8, 0, Math.PI * 2);
|
| 318 |
+
this.ctx.stroke();
|
| 319 |
+
|
| 320 |
+
// Head
|
| 321 |
+
this.ctx.fillStyle = '#ffcc00';
|
| 322 |
+
this.ctx.beginPath();
|
| 323 |
+
this.ctx.arc(screenX + enemy.width / 2, enemy.y + 10, 10, 0, Math.PI * 2);
|
| 324 |
+
this.ctx.fill();
|
| 325 |
+
|
| 326 |
+
// Eyes
|
| 327 |
+
this.ctx.fillStyle = '#fff';
|
| 328 |
+
this.ctx.fillRect(screenX + enemy.width / 2 - 6, enemy.y + 6, 5, 6);
|
| 329 |
+
this.ctx.fillRect(screenX + enemy.width / 2 + 1, enemy.y + 6, 5, 6);
|
| 330 |
+
|
| 331 |
+
// Pupils
|
| 332 |
+
this.ctx.fillStyle = '#000';
|
| 333 |
+
const pupilOffset = enemy.direction > 0 ? 2 : 0;
|
| 334 |
+
this.ctx.fillRect(screenX + enemy.width / 2 - 4 + pupilOffset, enemy.y + 8, 2, 3);
|
| 335 |
+
this.ctx.fillRect(screenX + enemy.width / 2 + 2 + pupilOffset, enemy.y + 8, 2, 3);
|
| 336 |
+
|
| 337 |
+
// Feet
|
| 338 |
+
const footOffset = Math.sin(this.animationFrame * 0.3) * 2;
|
| 339 |
+
this.ctx.fillStyle = '#ffcc00';
|
| 340 |
+
this.ctx.fillRect(screenX + 4, enemy.y + enemy.height - 6, 8, 6 + footOffset);
|
| 341 |
+
this.ctx.fillRect(screenX + enemy.width - 12, enemy.y + enemy.height - 6, 8, 6 - footOffset);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
drawFlag(position: Position, cameraX: number): void {
|
| 345 |
const screenX = position.x - cameraX;
|
| 346 |
|
|
|
|
| 373 |
this.ctx.fillText('★', screenX + 22 + flagWave / 2, position.y + 25);
|
| 374 |
}
|
| 375 |
|
| 376 |
+
drawUI(player: PlayerState, levelNumber: number = 1): void {
|
| 377 |
// UI Background
|
| 378 |
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
| 379 |
+
this.ctx.fillRect(10, 10, 280, 60);
|
| 380 |
|
| 381 |
// Score
|
| 382 |
this.ctx.fillStyle = '#fff';
|
|
|
|
| 393 |
|
| 394 |
this.ctx.fillStyle = '#fff';
|
| 395 |
this.ctx.fillText(`×${player.coins}`, 165, 40);
|
| 396 |
+
|
| 397 |
+
// Level indicator
|
| 398 |
+
this.ctx.fillStyle = '#fff';
|
| 399 |
+
this.ctx.fillText(`WORLD`, 220, 32);
|
| 400 |
+
this.ctx.fillText(`1-${levelNumber}`, 220, 52);
|
| 401 |
}
|
| 402 |
|
| 403 |
update(): void {
|
| 404 |
this.animationFrame++;
|
| 405 |
}
|
| 406 |
|
| 407 |
+
render(state: GameState, theme: LevelTheme = 'overworld', levelNumber: number = 1): void {
|
| 408 |
+
this.currentTheme = theme;
|
| 409 |
this.clear();
|
| 410 |
+
|
| 411 |
+
// Only draw clouds in overworld
|
| 412 |
+
if (theme === 'overworld') {
|
| 413 |
+
this.drawClouds(state.camera.x);
|
| 414 |
+
}
|
| 415 |
|
| 416 |
// Draw all blocks
|
| 417 |
state.blocks.forEach(block => this.drawBlock(block, state.camera.x));
|
|
|
|
| 426 |
this.drawPlayer(state.player, state.camera.x);
|
| 427 |
|
| 428 |
// Draw UI
|
| 429 |
+
this.drawUI(state.player, levelNumber);
|
| 430 |
|
| 431 |
this.update();
|
| 432 |
}
|