Add game source, converter pipeline, and web port

This commit is contained in:
2026-08-12 18:59:06 +02:00
parent 3e6635eac6
commit bd13ad67cc
97 changed files with 8781 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wackelpeter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
canvas { display: block; }
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; font-family: monospace; z-index: 10;
}
#health-bar-bg {
position: absolute; top: 16px; left: 16px; width: 250px; height: 20px;
background: rgba(0,0,0,0.6); border: 2px solid #555;
}
#health-bar {
position: absolute; top: 16px; left: 16px; width: 250px; height: 20px;
background: #cc0000;
transition: width 0.1s linear;
}
#health-text {
position: absolute; top: 16px; left: 16px; width: 250px; height: 20px;
text-align: center; line-height: 20px; color: #fff; font-size: 12px;
}
#wave-label {
position: absolute; top: 48px; left: 16px; color: #ffcc00; font-size: 18px;
font-family: monospace; text-shadow: 2px 2px 4px #000;
}
#kills-label {
position: absolute; top: 72px; left: 16px; color: #ffcc00; font-size: 16px;
font-family: monospace; text-shadow: 2px 2px 4px #000;
}
.cooldown-container {
position: absolute; left: 16px; width: 200px; height: 16px;
}
.cooldown-bg {
width: 100%; height: 100%; background: rgba(0,0,0,0.6); border: 1px solid #555;
}
.cooldown-fill {
height: 100%; background: #2266dd; transition: width 0.05s linear;
}
.cooldown-label {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
text-align: left; line-height: 16px; color: #fff; font-size: 11px;
padding-left: 4px; font-family: monospace;
}
#fireball-cooldown { bottom: 72px; }
#sword-cooldown { bottom: 48px; }
#teleport-cooldown { bottom: 24px; }
#wave-bar-container {
position: absolute; top: 16px; right: 16px; width: 250px; height: 16px;
}
#wave-bar-bg {
width: 100%; height: 100%; background: rgba(0,0,0,0.6); border: 1px solid #555;
}
#wave-bar-fill {
height: 100%; background: #ccaa00; transition: width 0.1s linear;
}
#wave-bar-label {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
text-align: center; line-height: 16px; color: #fff; font-size: 11px;
font-family: monospace;
}
#game-over {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
font-size: 100px; color: #fff; font-family: monospace; opacity: 0;
text-shadow: 4px 4px 8px #000;
transition: opacity 2s ease-in;
}
#instructions {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.5); font-size: 11px; font-family: monospace;
text-align: center;
}
</style>
</head>
<body>
<div id="ui-overlay">
<div id="health-bar-bg"></div>
<div id="health-bar"></div>
<div id="health-text">100%</div>
<div id="wave-label">Wave 0</div>
<div id="kills-label">Kills: 0</div>
<div id="fireball-cooldown" class="cooldown-container">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Fireball</div>
</div>
<div id="sword-cooldown" class="cooldown-container">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Sword</div>
</div>
<div id="teleport-cooldown" class="cooldown-container">
<div class="cooldown-bg"></div>
<div class="cooldown-fill"></div>
<div class="cooldown-label">Teleport</div>
</div>
<div id="wave-bar-container">
<div id="wave-bar-bg"></div>
<div id="wave-bar-fill"></div>
<div id="wave-bar-label">Next Wave...</div>
</div>
<div id="game-over">Verloren!</div>
<div id="instructions">
WASD: Move · Space: Jump · Mouse: Aim · Left Click: Fireball · Right Click: Sword · M3: Swap hands · E: Teleport
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1246
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "wackelpeter",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.170.0"
},
"devDependencies": {
"@types/three": "^0.170.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"skeletons":[],"geometries":[{"texcoords":[0.120075,0.879188,0.360224,0.879925,0.360961,0.639776,0.120812,0.639039,0.120812,0.158003,0.120075,0.398152,0.360224,0.398889,0.360961,0.15874,0.620443,0.399512,0.619706,0.159363,0.601111,0.15942,0.601848,0.399569,0.879188,0.399569,0.879925,0.15942,0.86133,0.159363,0.860593,0.399512,0.619706,0.879925,0.620443,0.639776,0.601848,0.639719,0.601111,0.879868,0.879188,0.879925,0.879925,0.639776,0.86133,0.639719,0.860593,0.879868],"indices":[0,1,2,0,2,3,4,5,6,4,6,7,8,9,10,8,10,11,12,13,14,12,14,15,16,17,18,16,18,19,20,21,22,20,22,23],"name":"tile-geom-1","elementCount":36,"positions":[1.0,0.0,-1.0,1.0,0.0,1.0,-1.0,0.0,1.0,-1.0,0.0,-1.0,-1.0,0.154864,1.0,1.0,0.154864,1.0,1.0,0.154864,-1.0,-1.0,0.154864,-1.0,1.0,0.0,-1.0,-1.0,0.0,-1.0,-1.0,0.154864,-1.0,1.0,0.154864,-1.0,1.0,0.0,1.0,1.0,0.0,-1.0,1.0,0.154864,-1.0,1.0,0.154864,1.0,-1.0,0.0,-1.0,-1.0,0.0,1.0,-1.0,0.154864,1.0,-1.0,0.154864,-1.0,-1.0,0.0,1.0,1.0,0.0,1.0,1.0,0.154864,1.0,-1.0,0.154864,1.0],"triangleCount":12,"normals":[0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,0.0,-1.0,0.0,0.0,-1.0,0.0,0.0,-1.0,0.0,0.0,-1.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0],"vertexCount":24}],"animations":{}}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"skeletons":[],"geometries":[{"texcoords":[0.120075,0.879188,0.360224,0.879925,0.360961,0.639776,0.120812,0.639039,0.120812,0.158003,0.120075,0.398152,0.360224,0.398889,0.360961,0.15874,0.620443,0.399512,0.619706,0.159363,0.601111,0.15942,0.601848,0.399569,0.879188,0.399569,0.879925,0.15942,0.86133,0.159363,0.860593,0.399512,0.619706,0.879925,0.620443,0.639776,0.601848,0.639719,0.601111,0.879868,0.879188,0.879925,0.879925,0.639776,0.86133,0.639719,0.860593,0.879868],"indices":[0,1,2,0,2,3,4,5,6,4,6,7,8,9,10,8,10,11,12,13,14,12,14,15,16,17,18,16,18,19,20,21,22,20,22,23],"name":"tile-geom-1","elementCount":36,"positions":[1.0,0.0,-1.0,1.0,0.0,1.0,-1.0,0.0,1.0,-1.0,0.0,-1.0,-1.0,0.154864,1.0,1.0,0.154864,1.0,1.0,0.154864,-1.0,-1.0,0.154864,-1.0,1.0,0.0,-1.0,-1.0,0.0,-1.0,-1.0,0.154864,-1.0,1.0,0.154864,-1.0,1.0,0.0,1.0,1.0,0.0,-1.0,1.0,0.154864,-1.0,1.0,0.154864,1.0,-1.0,0.0,-1.0,-1.0,0.0,1.0,-1.0,0.154864,1.0,-1.0,0.154864,-1.0,-1.0,0.0,1.0,1.0,0.0,1.0,1.0,0.154864,1.0,-1.0,0.154864,1.0],"triangleCount":12,"normals":[0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,-1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,1.0,-0.0,0.0,0.0,-1.0,0.0,0.0,-1.0,0.0,0.0,-1.0,0.0,0.0,-1.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,-1.0,-0.0,-0.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0,0.0,-0.0,1.0],"vertexCount":24}],"animations":{}}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"skeletons":[],"geometries":[{"texcoords":[0.998999,0.998999,0.667668,0.998999,0.667668,0.667668,0.998999,0.667668,0.332332,0.998999,0.001001,0.998999,0.001001,0.667668,0.332332,0.667668,0.332332,0.665666,0.001001,0.665666,0.001001,0.334334,0.332332,0.334334,0.665666,0.998999,0.334334,0.998999,0.334334,0.667668,0.665666,0.667668,0.665666,0.665666,0.334334,0.665666,0.334334,0.334334,0.665666,0.334334,0.668438,0.666433,0.666901,0.335105,0.998229,0.333567,0.999766,0.664895],"indices":[0,1,2,0,2,3,4,5,6,4,6,7,8,9,10,8,10,11,12,13,14,12,14,15,16,17,18,16,18,19,20,21,22,20,22,23],"name":"wall1","elementCount":36,"positions":[-0.999999,1.991615,1.000001,-0.999999,-0.008385,1.000002,1.000001,-0.008385,0.999999,1.000001,1.991615,0.999998,-1.000002,1.991613,-0.999999,0.999999,1.991613,-1.000002,0.999999,-0.008386,-1.000001,-1.000001,-0.008387,-0.999998,-0.999999,1.991615,1.000001,-1.000002,1.991613,-0.999999,-1.000001,-0.008387,-0.999998,-0.999999,-0.008385,1.000002,-0.999999,-0.008385,1.000002,-1.000001,-0.008387,-0.999998,0.999999,-0.008386,-1.000001,1.000001,-0.008385,0.999999,1.000001,-0.008385,0.999999,0.999999,-0.008386,-1.000001,0.999999,1.991613,-1.000002,1.000001,1.991615,0.999998,-1.000002,1.991613,-0.999999,-0.999999,1.991615,1.000001,1.000001,1.991615,0.999998,0.999999,1.991613,-1.000002],"triangleCount":12,"normals":[1.0E-6,1.0E-6,1.0,1.0E-6,1.0E-6,1.0,1.0E-6,1.0E-6,1.0,1.0E-6,1.0E-6,1.0,-1.0E-6,-1.0E-6,-1.0,-1.0E-6,-1.0E-6,-1.0,-1.0E-6,-1.0E-6,-1.0,-1.0E-6,-1.0E-6,-1.0,-1.0,-0.0,1.0E-6,-1.0,-0.0,1.0E-6,-1.0,-0.0,1.0E-6,-1.0,-0.0,1.0E-6,0.0,-1.0,1.0E-6,0.0,-1.0,1.0E-6,0.0,-1.0,1.0E-6,0.0,-1.0,1.0E-6,1.0,0.0,-1.0E-6,1.0,0.0,-1.0E-6,1.0,0.0,-1.0E-6,1.0,0.0,-1.0E-6,-0.0,1.0,-1.0E-6,-0.0,1.0,-1.0E-6,-0.0,1.0,-1.0E-6,-0.0,1.0,-1.0E-6],"vertexCount":24}],"animations":{}}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+50
View File
@@ -0,0 +1,50 @@
import * as THREE from 'three';
export class Arena extends THREE.Group {
constructor() {
super();
const tileSize = 10;
const half = tileSize / 2;
for (let x = -45; x < 50; x += tileSize) {
for (let z = -45; z < 50; z += tileSize) {
const isDark = ((x / tileSize + z / tileSize) & 1) === 0;
const color = isDark ? 0x668866 : 0x557755;
const tileGeom = new THREE.BoxGeometry(9.5, 0.3, 9.5);
const tileMat = new THREE.MeshStandardMaterial({
color,
roughness: 0.7,
metalness: 0.0,
});
const tile = new THREE.Mesh(tileGeom, tileMat);
tile.position.set(x, -0.15, z);
tile.receiveShadow = true;
this.add(tile);
}
}
// Walls
const wallGeom = new THREE.BoxGeometry(tileSize - 0.5, 2, 1);
const wallMat = new THREE.MeshToonMaterial({ color: 0x776677 });
for (let coord = -50; coord <= 50; coord += tileSize) {
this.createWall(coord, 49.5, wallGeom, wallMat);
this.createWall(coord, -49.5, wallGeom, wallMat);
}
for (let coord = -50; coord <= 50; coord += tileSize) {
this.createWall(49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
this.createWall(-49.5, coord, new THREE.BoxGeometry(1, 2, tileSize - 0.5), wallMat);
}
}
private createWall(x: number, z: number, geom: THREE.BoxGeometry, mat: THREE.Material) {
const wall = new THREE.Mesh(geom, mat);
wall.position.set(x, 1, z);
wall.castShadow = true;
wall.receiveShadow = true;
this.add(wall);
}
}
+125
View File
@@ -0,0 +1,125 @@
import * as THREE from 'three';
import { loadGLB } from './MeshLoader';
let crossGeometry: THREE.BufferGeometry | null = null;
let crossParticlesTemplate: THREE.Points | null = null;
export async function initCrossModel() {
try {
const gltf = await loadGLB('cross');
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
crossGeometry = child.geometry;
}
});
} catch {
console.warn('Failed to load cross model');
}
}
function createGreenParticles(): THREE.Points {
const count = 50;
const tex = new THREE.TextureLoader().load('./textures/flame.png');
tex.colorSpace = THREE.SRGBColorSpace;
const geom = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
map: tex,
color: 0x66ff66,
size: 0.12,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
const points = new THREE.Points(geom, mat);
points.renderOrder = 999;
points.userData = {
velocities: [] as THREE.Vector3[],
lifetimes: [] as number[],
ages: [] as number[],
timer: 0,
};
for (let i = 0; i < count; i++) {
positions[i * 3] = (Math.random() - 0.5) * 0.8;
positions[i * 3 + 1] = Math.random() * 1.2;
positions[i * 3 + 2] = (Math.random() - 0.5) * 0.8;
points.userData.velocities.push(new THREE.Vector3(
(Math.random() - 0.5) * 0.5,
1.5 + Math.random() * 1.5,
(Math.random() - 0.5) * 0.5
));
points.userData.lifetimes.push(1 + Math.random());
points.userData.ages.push(Math.random() * 2);
}
return points;
}
export class Cross {
body: THREE.Group;
private particles: THREE.Points;
constructor(position: THREE.Vector3) {
this.body = new THREE.Group();
this.body.position.copy(position);
this.body.position.y = 0.3;
if (crossGeometry) {
// Original material: red cross (from j3o material extraction)
const mesh = new THREE.Mesh(crossGeometry, new THREE.MeshToonMaterial({
color: 0xff0000, emissive: 0x440000,
}));
mesh.castShadow = true;
mesh.scale.set(0.25, 0.25, 0.25);
this.body.add(mesh);
} else {
// Fallback cross
const crossMaterial = new THREE.MeshToonMaterial({
color: 0xff0000, emissive: 0x440000,
});
const vertical = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.8, 0.15), crossMaterial);
vertical.position.y = 0.4;
this.body.add(vertical);
const horizontal = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.15, 0.15), crossMaterial);
horizontal.position.y = 0.5;
this.body.add(horizontal);
}
// Green glow
const glowGeom = new THREE.SphereGeometry(0.6, 16, 16);
const glowMat = new THREE.MeshBasicMaterial({
color: 0x00ff00, transparent: true, opacity: 0.15,
});
const glow = new THREE.Mesh(glowGeom, glowMat);
this.body.add(glow);
// Upward-flying light green particles (like the original)
this.particles = createGreenParticles();
this.body.add(this.particles);
}
update(dt: number) {
const attr = this.particles.geometry.getAttribute('position') as THREE.BufferAttribute;
const arr = attr.array as Float32Array;
const ud = this.particles.userData;
const count = ud.velocities.length;
for (let i = 0; i < count; i++) {
ud.ages[i] += dt;
const life = ud.lifetimes[i];
if (ud.ages[i] >= life) {
// Respawn at bottom
ud.ages[i] = 0;
arr[i * 3] = (Math.random() - 0.5) * 0.8;
arr[i * 3 + 1] = 0;
arr[i * 3 + 2] = (Math.random() - 0.5) * 0.8;
continue;
}
const v = ud.velocities[i];
arr[i * 3] += v.x * dt;
arr[i * 3 + 1] += v.y * dt;
arr[i * 3 + 2] += v.z * dt;
}
attr.needsUpdate = true;
}
}
+137
View File
@@ -0,0 +1,137 @@
import * as THREE from 'three';
import type { Game } from './Game';
import { playSound, playLoopingSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js';
let spiderGLTF: GLTF | null = null;
const ANIMATIONS_ENABLED = true;
export async function initSpiderModel() {
try {
spiderGLTF = await loadGLB('spider');
} catch {
console.warn('Failed to load spider model');
}
}
export class Enemy {
body: THREE.Group;
dead = false;
deathTimer = 0;
knockback = 0;
health = 100;
mixer: THREE.AnimationMixer | null = null;
private currentAnim = '';
private clips: Map<string, THREE.AnimationAction> = new Map();
private stopWalkSound: (() => void) | null = null;
constructor(_playerPos: THREE.Vector3) {
this.body = new THREE.Group();
this.stopWalkSound = playLoopingSound('spiderwalking', 0.3);
if (spiderGLTF) {
const model = cloneSkeleton(spiderGLTF.scene);
// Original game scaled the spider model by 0.5 (WalkingEnemy.setupModel)
model.scale.set(0.5, 0.5, 0.5);
const spiderTexture = new THREE.TextureLoader().load('./textures/spider_animation2.png');
spiderTexture.flipY = false;
spiderTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: spiderTexture });
}
});
this.body.add(model);
if (spiderGLTF.animations.length > 0 && ANIMATIONS_ENABLED) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of spiderGLTF.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
this.playAnim('stand');
}
} else {
// Fallback
const abdomenGeom = new THREE.SphereGeometry(0.5, 8, 8);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x333333 });
const abdomen = new THREE.Mesh(abdomenGeom, bodyMat);
abdomen.scale.set(1, 0.6, 1.3);
abdomen.position.y = 0.6;
abdomen.castShadow = true;
this.body.add(abdomen);
const cephalothoraxGeom = new THREE.SphereGeometry(0.3, 8, 8);
const cephalothorax = new THREE.Mesh(cephalothoraxGeom, bodyMat);
cephalothorax.position.y = 0.6;
cephalothorax.position.z = 0.5;
cephalothorax.castShadow = true;
this.body.add(cephalothorax);
const eyeGeom = new THREE.SphereGeometry(0.06, 6, 6);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (const sign of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeom, eyeMat);
eye.position.set(sign * 0.12, 0.85, 0.7);
this.body.add(eye);
}
const legGeom = new THREE.CylinderGeometry(0.04, 0.04, 0.8, 4);
for (let i = 0; i < 8; i++) {
const leg = new THREE.Mesh(legGeom, new THREE.MeshToonMaterial({ color: 0x222222 }));
const angle = (i / 8) * Math.PI * 2;
const side = i < 4 ? 1 : -1;
leg.position.set(Math.cos(angle) * 0.35, 0.3, Math.sin(angle) * 0.35);
leg.rotation.z = side * 0.6;
leg.rotation.x = angle;
leg.castShadow = true;
this.body.add(leg);
}
}
}
playAnim(name: string, loop = true, speed = 1) {
if (!this.clips.has(name) || this.currentAnim === name) return;
// Stop the previous animation so it doesn't blend with the new one
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.play();
}
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
}
takeDamage(damage: number, game: Game) {
if (this.dead) return;
this.health -= damage;
this.knockback = 0.5;
playSound('damage', 0.7);
if (this.health <= 0) {
this.die(game);
}
}
private die(game: Game) {
if (this.dead) return;
this.dead = true;
this.playAnim('die', false);
if (this.stopWalkSound) {
this.stopWalkSound();
this.stopWalkSound = null;
}
game.removeEnemy(this);
}
}
+253
View File
@@ -0,0 +1,253 @@
import * as THREE from 'three';
import { playSound } from './SoundManager';
const flameTexture = new THREE.TextureLoader().load('./textures/flame.png');
flameTexture.colorSpace = THREE.SRGBColorSpace;
export class Fireball {
body: THREE.Group;
light: THREE.PointLight;
direction: THREE.Vector3;
lifetime = 1;
exploding = false;
timer = 0;
damageDone = false;
private trailParticles: THREE.Points;
private trailVelocities: Float32Array;
private trailLives: Float32Array;
private trailMaxLife = 0.6;
constructor(start: THREE.Vector3, direction: THREE.Vector3) {
this.direction = direction.clone();
this.body = new THREE.Group();
this.body.position.copy(start);
// Fireball core (additive glow)
const coreGeom = new THREE.SphereGeometry(0.45, 16, 16);
const coreMat = new THREE.MeshBasicMaterial({
color: 0xffcc44,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
const core = new THREE.Mesh(coreGeom, coreMat);
this.body.add(core);
const glowGeom = new THREE.SphereGeometry(0.75, 16, 16);
const glowMat = new THREE.MeshBasicMaterial({
color: 0xff6600, transparent: true, opacity: 0.45,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const glow = new THREE.Mesh(glowGeom, glowMat);
this.body.add(glow);
// Sprite with flame texture
const spriteMat = new THREE.SpriteMaterial({
map: flameTexture,
color: 0xffaa33,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(2.2, 2.2, 1);
this.body.add(sprite);
// Trail particles (flame sprites behind the fireball)
const trailCount = 30;
const trailGeom = new THREE.BufferGeometry();
const positions = new Float32Array(trailCount * 3);
trailGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const trailMat = new THREE.PointsMaterial({
map: flameTexture,
color: 0xff8833,
size: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
opacity: 0.7,
});
this.trailParticles = new THREE.Points(trailGeom, trailMat);
this.body.add(this.trailParticles);
this.trailVelocities = new Float32Array(trailCount * 3);
this.trailLives = new Float32Array(trailCount).fill(0);
// Strong dynamic light (original: radius 100, orange, with shadow renderer)
this.light = new THREE.PointLight(0xff7722, 80, 45, 1.5);
this.light.position.set(0, 0.5, 0);
this.light.castShadow = true;
this.light.shadow.mapSize.width = 256;
this.light.shadow.mapSize.height = 256;
this.light.shadow.camera.near = 0.5;
this.light.shadow.camera.far = 45;
this.light.shadow.bias = -0.005;
this.body.add(this.light);
}
updateTrail(dt: number, pos: THREE.Vector3) {
const attr = this.trailParticles.geometry.getAttribute('position') as THREE.BufferAttribute;
const arr = attr.array as Float32Array;
for (let i = 0; i < this.trailLives.length; i++) {
if (this.trailLives[i] > 0) {
this.trailLives[i] -= dt;
arr[i * 3] += this.trailVelocities[i * 3] * dt;
arr[i * 3 + 1] += this.trailVelocities[i * 3 + 1] * dt;
arr[i * 3 + 2] += this.trailVelocities[i * 3 + 2] * dt;
if (this.trailLives[i] <= 0) {
arr[i * 3] = pos.x;
arr[i * 3 + 1] = pos.y;
arr[i * 3 + 2] = pos.z;
}
} else {
// Spawn new trail particle
arr[i * 3] = pos.x;
arr[i * 3 + 1] = pos.y;
arr[i * 3 + 2] = pos.z;
this.trailVelocities[i * 3] = (Math.random() - 0.5) * 1.5;
this.trailVelocities[i * 3 + 1] = (Math.random() - 0.5) * 1.5;
this.trailVelocities[i * 3 + 2] = (Math.random() - 0.5) * 1.5;
this.trailLives[i] = this.trailMaxLife * (0.5 + Math.random());
}
}
attr.needsUpdate = true;
}
explode(scene: THREE.Scene) {
if (this.exploding) return;
this.exploding = true;
this.timer = 0;
this.damageDone = false;
playSound('explosion', 0.6);
// Hide the fireball core and trail
for (const child of [...this.body.children]) {
if (child instanceof THREE.Mesh || child instanceof THREE.Sprite || child instanceof THREE.Points) {
child.visible = false;
}
}
// Bright flash
const flashGeom = new THREE.SphereGeometry(3, 16, 16);
const flashMat = new THREE.MeshBasicMaterial({
color: 0xffdd66, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const flash = new THREE.Mesh(flashGeom, flashMat);
flash.position.copy(this.body.position);
scene.add(flash);
// Big explosion light pulse
const flashLight = new THREE.PointLight(0xff8833, 200, 35, 1.5);
flashLight.position.copy(this.body.position);
scene.add(flashLight);
const startTime = performance.now();
const updateFlash = () => {
const elapsed = (performance.now() - startTime) / 1000;
if (elapsed > 0.6) {
flash.removeFromParent();
flash.geometry.dispose();
flashMat.dispose();
scene.remove(flashLight);
return;
}
const scale = 1 + elapsed * 8;
flash.scale.set(scale, scale, scale);
flashMat.opacity = 0.9 * (1 - elapsed / 0.6);
flashLight.intensity = 200 * (1 - elapsed / 0.6);
requestAnimationFrame(updateFlash);
};
updateFlash();
// Fire particle burst (original: 50 flame particles)
this.spawnExplosionParticles(scene, './textures/flame.png', 0xffaa33, 60, 8, 1.5);
// Debris stones (original: stone particles with gravity)
this.spawnExplosionParticles(scene, './textures/flame.png', 0x555544, 25, 6, 1.0, true);
}
private spawnExplosionParticles(
scene: THREE.Scene,
texPath: string,
color: number,
count: number,
speed: number,
size: number,
withGravity = false
) {
const tex = new THREE.TextureLoader().load(texPath);
tex.colorSpace = THREE.SRGBColorSpace;
const geom = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities: THREE.Vector3[] = [];
const lives: number[] = [];
const maxLives: number[] = [];
for (let i = 0; i < count; i++) {
positions[i * 3] = this.body.position.x;
positions[i * 3 + 1] = this.body.position.y;
positions[i * 3 + 2] = this.body.position.z;
velocities.push(new THREE.Vector3(
(Math.random() - 0.5) * speed * 2,
Math.random() * speed,
(Math.random() - 0.5) * speed * 2
));
const life = 0.5 + Math.random() * 1.2;
lives.push(life);
maxLives.push(life);
}
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
map: tex,
color,
size,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
});
const points = new THREE.Points(geom, mat);
points.renderOrder = 999;
scene.add(points);
const startTime = performance.now();
const update = () => {
const elapsed = (performance.now() - startTime) / 1000;
if (elapsed > 2) {
points.removeFromParent();
geom.dispose();
mat.dispose();
tex.dispose();
return;
}
const attr = geom.getAttribute('position') as THREE.BufferAttribute;
const arr = attr.array as Float32Array;
let allDead = true;
for (let i = 0; i < count; i++) {
lives[i] -= 0.016;
if (lives[i] <= 0) continue;
allDead = false;
const v = velocities[i];
if (withGravity) v.y -= 9.8 * 0.016;
arr[i * 3] += v.x * 0.016;
arr[i * 3 + 1] += v.y * 0.016;
arr[i * 3 + 2] += v.z * 0.016;
}
attr.needsUpdate = true;
mat.opacity = Math.min(1, elapsed / 2) * 0.9;
if (allDead) {
points.removeFromParent();
geom.dispose();
mat.dispose();
tex.dispose();
return;
}
requestAnimationFrame(update);
};
update();
}
}
+585
View File
@@ -0,0 +1,585 @@
import * as THREE from 'three';
import { Player } from './Player';
import { Enemy, initSpiderModel } from './Enemy';
import { Sword } from './Sword';
import { Staff } from './Staff';
import { Fireball } from './Fireball';
import { Cross, initCrossModel } from './Cross';
import { Arena } from './Arena';
import { initSounds, playSound, resumeContext } from './SoundManager';
const SPAWN_TIME = 10;
export class Game {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
clock: THREE.Clock;
player!: Player;
enemies: Enemy[] = [];
fireballs: Fireball[] = [];
crosses: Cross[] = [];
keys: Set<string> = new Set();
mouseButtons: Set<number> = new Set();
private prevMouseButtons: Set<number> = new Set();
mouseX = 0;
mouseY = 0;
groundPoint = new THREE.Vector3();
spawnTimer = 0;
spawns = 0;
killed = 0;
gameOver = false;
// Debug hitzone visualization
private showHitzones = false;
private playerHitRing!: THREE.Mesh;
private enemyHitRings: Map<Enemy, THREE.Mesh> = new Map();
private fireballHitRings: Map<Fireball, THREE.Mesh> = new Map();
private crossHitRings: Map<Cross, THREE.Mesh> = new Map();
// UI elements
private uiHealthBar!: HTMLElement;
private uiHealthText!: HTMLElement;
private uiWaveLabel!: HTMLElement;
private uiKillsLabel!: HTMLElement;
private uiFireballCD!: HTMLElement;
private uiSwordCD!: HTMLElement;
private uiTeleportCD!: HTMLElement;
private uiWaveBarFill!: HTMLElement;
private uiWaveBarLabel!: HTMLElement;
private uiGameOver!: HTMLElement;
private mouseOnCanvas = false;
constructor() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0d1117);
this.scene.fog = new THREE.Fog(0x0d1117, 25, 70);
this.camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 1, 150
);
this.camera.position.set(0, 25, -15);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.prepend(this.renderer.domElement);
this.clock = new THREE.Clock();
this.setupUI();
this.setupInput();
this.setupLighting();
}
async init() {
this.setupArena();
this.setupPlayer();
this.player.setGame(this);
initSounds();
await Promise.all([
this.player.init(),
this.player.rightHandWeapon.init(),
this.player.leftHandWeapon.init(),
initSpiderModel(),
initCrossModel(),
]);
}
start() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
this.animate();
}
private setupUI() {
this.uiHealthBar = document.getElementById('health-bar')!;
this.uiHealthText = document.getElementById('health-text')!;
this.uiWaveLabel = document.getElementById('wave-label')!;
this.uiKillsLabel = document.getElementById('kills-label')!;
this.uiFireballCD = document.querySelector('#fireball-cooldown .cooldown-fill')!;
this.uiSwordCD = document.querySelector('#sword-cooldown .cooldown-fill')!;
this.uiTeleportCD = document.querySelector('#teleport-cooldown .cooldown-fill')!;
this.uiWaveBarFill = document.getElementById('wave-bar-fill')!;
this.uiWaveBarLabel = document.getElementById('wave-bar-label')!;
this.uiGameOver = document.getElementById('game-over')!;
const canvas = this.renderer.domElement;
canvas.addEventListener('mouseenter', () => this.mouseOnCanvas = true);
canvas.addEventListener('mouseleave', () => this.mouseOnCanvas = false);
canvas.addEventListener('contextmenu', e => e.preventDefault());
}
private setupInput() {
window.addEventListener('keydown', (e) => {
this.keys.add(e.code);
if (e.code === 'Space') {
this.player.jump();
e.preventDefault();
}
if (e.code === 'KeyH') {
this.showHitzones = !this.showHitzones;
}
});
window.addEventListener('keyup', (e) => {
this.keys.delete(e.code);
if (e.code === 'KeyE' && !this.gameOver) {
this.player.leftHandWeapon.skill2(this.groundPoint, this.player);
}
});
window.addEventListener('mousedown', (e) => {
resumeContext();
this.mouseButtons.add(e.button);
this.prevMouseButtons.add(e.button);
});
window.addEventListener('mouseup', (e) => {
resumeContext();
this.mouseButtons.delete(e.button);
this.handleMouseRelease(e.button);
});
window.addEventListener('mousemove', (e) => {
this.mouseX = (e.clientX / window.innerWidth) * 2 - 1;
this.mouseY = -(e.clientY / window.innerHeight) * 2 + 1;
});
}
private handleMouseRelease(button: number) {
if (this.gameOver) return;
if (button === 0) {
this.player.leftHandWeapon.skill1(this.groundPoint, this.player);
} else if (button === 1) {
this.player.switchHands();
} else if (button === 2) {
this.player.rightHandWeapon.skill1(this.groundPoint, this.player);
}
}
private setupLighting() {
const ambient = new THREE.AmbientLight(0x556688, 0.35);
this.scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xccddff, 0.55);
dirLight.position.set(30, 40, 20);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 150;
dirLight.shadow.camera.left = -60;
dirLight.shadow.camera.right = 60;
dirLight.shadow.camera.top = 60;
dirLight.shadow.camera.bottom = -60;
this.scene.add(dirLight);
}
private setupArena() {
const arena = new Arena();
this.scene.add(arena);
}
private setupPlayer() {
this.player = new Player();
this.player.body.position.set(0, 0.5, 0);
this.scene.add(this.player.body);
// Player hitzone ring (contact damage radius = 2)
this.playerHitRing = this.createHitRing(2, 0xff0000);
this.scene.add(this.playerHitRing);
}
private createHitRing(radius: number, color: number): THREE.Mesh {
const geom = new THREE.RingGeometry(radius - 0.05, radius, 48);
const mat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(geom, mat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.05;
ring.renderOrder = 999;
return ring;
}
private updateHitzones() {
this.playerHitRing.position.x = this.player.body.position.x;
this.playerHitRing.position.z = this.player.body.position.z;
this.playerHitRing.visible = this.showHitzones;
// Enemy rings
for (const enemy of this.enemies) {
let ring = this.enemyHitRings.get(enemy);
if (!ring) {
ring = this.createHitRing(2, 0xff8800);
this.scene.add(ring);
this.enemyHitRings.set(enemy, ring);
}
ring.position.x = enemy.body.position.x;
ring.position.z = enemy.body.position.z;
ring.visible = this.showHitzones && !enemy.dead;
}
// Clean up removed enemies
for (const [enemy, ring] of this.enemyHitRings) {
if (!this.enemies.includes(enemy)) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.enemyHitRings.delete(enemy);
}
}
// Fireball rings (impact radius 1.5 / AoE 5)
for (const fb of this.fireballs) {
let ring = this.fireballHitRings.get(fb);
if (!ring) {
ring = this.createHitRing(fb.exploding ? 5 : 1.5, fb.exploding ? 0xffff00 : 0xff6600);
this.scene.add(ring);
this.fireballHitRings.set(fb, ring);
}
ring.position.x = fb.body.position.x;
ring.position.z = fb.body.position.z;
ring.visible = this.showHitzones;
}
for (const [fb, ring] of this.fireballHitRings) {
if (!this.fireballs.includes(fb)) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.fireballHitRings.delete(fb);
}
}
// Cross rings (pickup radius 2)
for (const cross of this.crosses) {
let ring = this.crossHitRings.get(cross);
if (!ring) {
ring = this.createHitRing(2, 0x00ff00);
this.scene.add(ring);
this.crossHitRings.set(cross, ring);
}
ring.position.x = cross.body.position.x;
ring.position.z = cross.body.position.z;
ring.visible = this.showHitzones;
}
for (const [cross, ring] of this.crossHitRings) {
if (!this.crosses.includes(cross)) {
this.scene.remove(ring);
ring.geometry.dispose();
(ring.material as THREE.Material).dispose();
this.crossHitRings.delete(cross);
}
}
}
private spawnEnemy() {
const x = (Math.random() - 0.5) * 80;
const z = (Math.random() - 0.5) * 80;
const enemy = new Enemy(this.player.body.position);
enemy.body.position.set(x, 0.1, z);
this.scene.add(enemy.body);
this.enemies.push(enemy);
}
private spawnCross(position: THREE.Vector3) {
if (Math.random() > 0.9) {
const cross = new Cross(position.clone());
this.scene.add(cross.body);
this.crosses.push(cross);
}
}
private updateGroundPoint() {
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(this.mouseX, this.mouseY), this.camera);
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const intersection = new THREE.Vector3();
if (raycaster.ray.intersectPlane(plane, intersection)) {
intersection.y = 0;
intersection.clamp(
new THREE.Vector3(-48, 0, -48),
new THREE.Vector3(48, 0, 48)
);
this.groundPoint.copy(intersection);
}
}
private updatePlayerMovement(dt: number) {
if (this.gameOver) {
this.player.body.position.y = 0.5;
return;
}
const camFwd = new THREE.Vector3();
this.camera.getWorldDirection(camFwd);
camFwd.y = 0;
camFwd.normalize();
const camLeft = new THREE.Vector3();
camLeft.crossVectors(new THREE.Vector3(0, 1, 0), camFwd).normalize();
const moveDir = new THREE.Vector3();
if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) moveDir.add(camFwd);
if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) moveDir.sub(camFwd);
if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) moveDir.add(camLeft);
if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) moveDir.sub(camLeft);
let speed = 7.5;
let isMoving = false;
if (moveDir.length() > 0) {
moveDir.normalize();
this.player.velocity.set(moveDir.x * speed, 0, moveDir.z * speed);
isMoving = true;
} else {
this.player.velocity.set(0, 0, 0);
}
// Play walk/stand animations
if (isMoving && this.player.getCurrentAnimation() !== 'walk') {
this.player.playAnimation('walk');
} else if (!isMoving && this.player.getCurrentAnimation() !== 'stand') {
this.player.playAnimation('stand');
}
// Jump / gravity
if (!this.player.onGround) {
this.player.jumpVelocity -= 20 * dt;
this.player.body.position.y += this.player.jumpVelocity * dt;
if (this.player.body.position.y <= 0.5) {
this.player.body.position.y = 0.5;
this.player.jumpVelocity = 0;
this.player.onGround = true;
}
}
// Apply horizontal movement
this.player.body.position.x += this.player.velocity.x * dt;
this.player.body.position.z += this.player.velocity.z * dt;
this.clampToArena(this.player.body.position);
// Look at ground point
this.player.body.lookAt(
this.groundPoint.x,
this.player.body.position.y,
this.groundPoint.z
);
}
private clampToArena(pos: THREE.Vector3) {
pos.x = Math.max(-47, Math.min(47, pos.x));
pos.z = Math.max(-47, Math.min(47, pos.z));
}
private updateEnemies(dt: number) {
for (const enemy of this.enemies) {
if (enemy.dead) {
enemy.updateAnimation(dt);
continue;
}
const toPlayer = new THREE.Vector3()
.subVectors(this.player.body.position, enemy.body.position);
toPlayer.y = 0;
const dist = toPlayer.length();
if (dist > 0.1) {
const dir = toPlayer.normalize();
enemy.body.lookAt(
enemy.body.position.x + dir.x,
enemy.body.position.y,
enemy.body.position.z + dir.z
);
enemy.playAnim('walk');
let moveDir = dir.multiplyScalar(6);
if (enemy.knockback > 0) {
moveDir = dir.multiplyScalar(-6);
enemy.knockback -= dt;
}
enemy.body.position.x += moveDir.x * dt;
enemy.body.position.z += moveDir.z * dt;
this.clampToArena(enemy.body.position);
} else {
enemy.playAnim('stand');
}
enemy.updateAnimation(dt);
}
}
private updateFireballs(dt: number) {
for (let i = this.fireballs.length - 1; i >= 0; i--) {
const fb = this.fireballs[i];
fb.lifetime -= dt;
if (fb.exploding) {
fb.timer += dt;
if (fb.timer > 0.2 && !fb.damageDone) {
fb.damageDone = true;
for (const enemy of this.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(fb.body.position);
if (dist < 8) {
enemy.takeDamage(100, this);
}
}
}
if (fb.timer > 2) {
this.scene.remove(fb.body);
this.fireballs.splice(i, 1);
}
continue;
}
fb.body.position.x += fb.direction.x * dt;
fb.body.position.z += fb.direction.z * dt;
fb.updateTrail(dt, fb.body.position);
for (const enemy of this.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(fb.body.position);
if (dist < 1.8) {
fb.explode(this.scene);
break;
}
}
if (fb.lifetime <= 0) {
fb.explode(this.scene);
}
}
}
private updateUI() {
const healthPct = this.player.health / this.player.MAX_HEALTH;
this.uiHealthBar.style.width = `${Math.max(0, healthPct * 250)}px`;
this.uiHealthText.textContent = `${Math.ceil(this.player.health)}%`;
this.uiWaveLabel.textContent = `Wave ${this.spawns}`;
this.uiKillsLabel.textContent = `Kills: ${this.killed}`;
const sword = this.player.rightHandWeapon as Sword;
const staff = this.player.leftHandWeapon as Staff;
const fbPct = Math.max(0, (staff.COOLDOWN1_TIME - staff.cooldown1) / staff.COOLDOWN1_TIME);
this.uiFireballCD.style.width = `${fbPct * 100}%`;
const swordPct = Math.max(0, (sword.COOLDOWN1_TIME - sword.cooldown1) / sword.COOLDOWN1_TIME);
this.uiSwordCD.style.width = `${swordPct * 100}%`;
const tpPct = Math.max(0, (staff.COOLDOWN2_TIME - staff.cooldown2) / staff.COOLDOWN2_TIME);
this.uiTeleportCD.style.width = `${tpPct * 100}%`;
const wavePct = this.spawnTimer / SPAWN_TIME;
this.uiWaveBarFill.style.width = `${wavePct * 100}%`;
this.uiWaveBarLabel.textContent = `Next Wave: ${Math.ceil(SPAWN_TIME - this.spawnTimer)}s`;
if (this.player.health <= 0 && !this.gameOver) {
this.gameOver = true;
this.player.die();
this.uiGameOver.style.opacity = '1';
playSound('gameover', 0.8);
}
}
private animate() {
requestAnimationFrame(() => this.animate());
const dt = Math.min(this.clock.getDelta(), 0.1);
this.updateGroundPoint();
this.updatePlayerMovement(dt);
this.updateEnemies(dt);
this.updateFireballs(dt);
this.updateHitzones();
// Update animations
this.player.updateAnimations(dt);
this.player.rightHandWeapon.updateAnimation(dt);
// Spawn enemies in waves
if (!this.gameOver) {
this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_TIME) {
this.spawns++;
for (let i = 0; i < this.spawns; i++) {
this.spawnEnemy();
}
this.spawnTimer = 0;
}
}
// Check close encounters (spider touching player)
for (const enemy of this.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(this.player.body.position);
if (dist < 2) {
this.player.health -= 25 * dt;
}
}
// Cross collisions
for (let i = this.crosses.length - 1; i >= 0; i--) {
const cross = this.crosses[i];
cross.update(dt);
const dist = cross.body.position.distanceTo(this.player.body.position);
if (dist < 2) {
this.player.heal(25);
this.scene.remove(cross.body);
this.crosses.splice(i, 1);
}
}
// Update cooldowns
this.player.rightHandWeapon.reduceCooldowns(dt);
this.player.leftHandWeapon.reduceCooldowns(dt);
// Camera follows player
this.camera.position.set(
this.player.body.position.x,
25,
this.player.body.position.z - 15
);
this.camera.lookAt(
this.player.body.position.x,
0,
this.player.body.position.z
);
// Clean dead enemies
for (let i = this.enemies.length - 1; i >= 0; i--) {
const enemy = this.enemies[i];
if (enemy.dead) {
enemy.deathTimer += dt;
if (enemy.deathTimer > 10) {
this.scene.remove(enemy.body);
this.enemies.splice(i, 1);
}
}
}
this.updateUI();
this.renderer.render(this.scene, this.camera);
}
removeEnemy(enemy: Enemy) {
enemy.dead = true;
this.killed++;
this.spawnCross(enemy.body.position);
}
}
+15
View File
@@ -0,0 +1,15 @@
import * as THREE from 'three';
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
export async function loadGLB(name: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
loader.load(
`./models/${name}.glb`,
(gltf) => resolve(gltf),
undefined,
(err) => reject(err)
);
});
}
+175
View File
@@ -0,0 +1,175 @@
import * as THREE from 'three';
import { Sword } from './Sword';
import { Staff } from './Staff';
import { loadGLB } from './MeshLoader';
import type { Game } from './Game';
export class Player {
body: THREE.Group;
leftHandWeapon: Staff;
rightHandWeapon: Sword;
velocity = new THREE.Vector3();
health = 100;
MAX_HEALTH = 100;
game!: Game;
jumpVelocity = 0;
onGround = true;
mixer!: THREE.AnimationMixer;
private clips: Map<string, THREE.AnimationAction> = new Map();
private currentAnim = '';
constructor() {
this.body = new THREE.Group();
// Helmet
const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2);
const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 });
const helmet = new THREE.Mesh(helmetGeom, helmetMat);
helmet.position.y = 0.9;
helmet.castShadow = true;
this.body.add(helmet);
// Try loading the real helmet model (replaces the placeholder sphere)
loadGLB('helmet').then((gltf) => {
const realHelmet = gltf.scene;
const helmetTexture = new THREE.TextureLoader().load('./textures/helmet.png');
helmetTexture.flipY = false;
helmetTexture.colorSpace = THREE.SRGBColorSpace;
realHelmet.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: helmetTexture });
}
});
realHelmet.scale.set(0.35, 0.35, 0.35);
realHelmet.position.set(0, 0.9, -0.2);
realHelmet.rotation.set(-0.4, 0, 0);
this.body.remove(helmet);
this.body.add(realHelmet);
}).catch(() => {
// Keep placeholder helmet
});
// Shadow disc
const shadowGeom = new THREE.CircleGeometry(0.4, 16);
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x000000, transparent: true, opacity: 0.3,
});
const shadow = new THREE.Mesh(shadowGeom, shadowMat);
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = -0.48;
shadow.renderOrder = 999;
this.body.add(shadow);
// Weapons
this.leftHandWeapon = new Staff();
this.rightHandWeapon = new Sword();
this.body.add(this.leftHandWeapon.mesh);
this.body.add(this.rightHandWeapon.mesh);
}
async init() {
try {
const gltf = await loadGLB('blob');
const model = gltf.scene;
model.scale.set(0.5, 0.5, 0.5);
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
const mat = new THREE.MeshToonMaterial({
color: 0x44aa44,
});
child.material = mat;
}
});
this.body.add(model);
if (gltf.animations.length > 0) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of gltf.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
this.playAnimation('stand');
}
} catch (e) {
console.warn('Failed to load blob model, using fallback', e);
// Fallback capsule
const bodyGeom = new THREE.CapsuleGeometry(0.5, 0.5, 8, 16);
const bodyMat = new THREE.MeshToonMaterial({ color: 0x44aa44 });
const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat);
bodyMesh.position.y = 0.75;
bodyMesh.castShadow = true;
this.body.add(bodyMesh);
}
}
playAnimation(name: string, loop = true, speed = 1) {
if (!this.clips.has(name)) return;
if (this.currentAnim === name) return;
// Stop the previous animation so it doesn't blend with the new one
if (this.currentAnim) {
const prev = this.clips.get(this.currentAnim);
if (prev) prev.stop();
}
this.currentAnim = name;
const action = this.clips.get(name)!;
action.reset();
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
action.clampWhenFinished = !loop;
action.setEffectiveTimeScale(speed);
action.weight = 1;
action.play();
return action;
}
stopAnimation(name: string) {
const action = this.clips.get(name);
if (action) action.stop();
}
getCurrentAnimation(): string {
return this.currentAnim;
}
updateAnimations(dt: number) {
if (this.mixer) this.mixer.update(dt);
}
setGame(game: Game) {
this.game = game;
}
jump() {
if (this.onGround) {
this.jumpVelocity = 8;
this.onGround = false;
this.playAnimation('jump', false);
}
}
switchHands() {
const temp = this.leftHandWeapon;
this.leftHandWeapon = this.rightHandWeapon as unknown as Staff;
this.rightHandWeapon = temp as unknown as Sword;
const leftPos = this.leftHandWeapon.mesh.position.clone();
const rightPos = this.rightHandWeapon.mesh.position.clone();
this.leftHandWeapon.mesh.position.copy(rightPos);
this.rightHandWeapon.mesh.position.copy(leftPos);
const leftRot = this.leftHandWeapon.mesh.rotation.clone();
const rightRot = this.rightHandWeapon.mesh.rotation.clone();
this.leftHandWeapon.mesh.rotation.copy(rightRot);
this.rightHandWeapon.mesh.rotation.copy(leftRot);
}
heal(amount: number) {
this.health = Math.min(this.health + amount, this.MAX_HEALTH);
}
die() {
this.playAnimation('die', false);
}
}
+61
View File
@@ -0,0 +1,61 @@
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
const sounds: Record<string, AudioBuffer> = {};
async function loadSound(name: string): Promise<AudioBuffer> {
const response = await fetch(`./sounds/${name}.ogg`);
const arrayBuffer = await response.arrayBuffer();
return audioCtx.decodeAudioData(arrayBuffer);
}
export async function initSounds() {
const names = ['fireball', 'explosion', 'sword_hit1', 'teleport', 'damage', 'spiderwalking', 'spawn', 'gameover'];
for (const name of names) {
try {
sounds[name] = await loadSound(name);
} catch {
console.warn(`Sound ${name} not found`);
}
}
}
export function resumeContext() {
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
export function playSound(name: string, volume = 1) {
const buffer = sounds[name];
if (!buffer) return;
if (audioCtx.state === 'suspended') return;
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = volume;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start(0);
}
export function playLoopingSound(name: string, volume = 1): (() => void) | null {
const buffer = sounds[name];
if (!buffer) return null;
if (audioCtx.state === 'suspended') return null;
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.loop = true;
const gain = audioCtx.createGain();
gain.gain.value = volume;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start(0);
return () => {
try {
source.stop();
} catch {
// already stopped
}
source.disconnect();
};
}
+85
View File
@@ -0,0 +1,85 @@
import * as THREE from 'three';
import { Weapon } from './Weapon';
import { Player } from './Player';
import { Fireball } from './Fireball';
import { playSound } from './SoundManager';
import { loadGLB } from './MeshLoader';
export class Staff extends Weapon {
constructor() {
super();
this.COOLDOWN1_TIME = 5;
this.COOLDOWN2_TIME = 50;
// Fallback geometry
const shaftGeom = new THREE.CylinderGeometry(0.04, 0.05, 1.0, 8);
const crystalGeom = new THREE.OctahedronGeometry(0.08);
const woodMat = new THREE.MeshToonMaterial({ color: 0x8B4513 });
const crystalMat = new THREE.MeshToonMaterial({ color: 0x4488ff });
const shaft = new THREE.Mesh(shaftGeom, woodMat);
shaft.position.y = 0.3;
shaft.castShadow = true;
const crystal = new THREE.Mesh(crystalGeom, crystalMat);
crystal.position.y = 0.9;
this.mesh.add(shaft);
this.mesh.add(crystal);
this.mesh.position.set(0.35, 0.3, 0.35);
this.mesh.scale.set(0.35, 0.35, 0.35);
this.mesh.rotation.set(-0.2, 0, -0.2);
}
async init() {
try {
const gltf = await loadGLB('staff');
const model = gltf.scene;
const staffTexture = new THREE.TextureLoader().load('./textures/staff1.png');
staffTexture.flipY = false;
staffTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: staffTexture });
}
});
while (this.mesh.children.length > 0) {
this.mesh.remove(this.mesh.children[0]);
}
this.mesh.add(model);
} catch {
// Keep fallback
}
}
skill1(groundPoint: THREE.Vector3, player: Player): void {
if (this.cooldown1 > 0) return;
this.cooldown1 = this.COOLDOWN1_TIME;
// Spawn at staff height, fly toward the cursor point
const spawnPos = player.body.position.clone();
spawnPos.y += 0.8;
const dir = new THREE.Vector3()
.subVectors(groundPoint, spawnPos)
.normalize()
.multiplyScalar(25);
dir.setY(0);
const fb = new Fireball(spawnPos, dir);
player.game.scene.add(fb.body);
player.game.fireballs.push(fb);
playSound('fireball', 0.5);
}
skill2(groundPoint: THREE.Vector3, player: Player): void {
if (this.cooldown2 > 0) return;
this.cooldown2 = this.COOLDOWN2_TIME;
const newPos = groundPoint.clone();
newPos.y = 0.5;
newPos.x = Math.max(-47, Math.min(47, newPos.x));
newPos.z = Math.max(-47, Math.min(47, newPos.z));
player.body.position.copy(newPos);
playSound('teleport', 0.5);
}
}
+119
View File
@@ -0,0 +1,119 @@
import * as THREE from 'three';
import { Weapon } from './Weapon';
import { Player } from './Player';
import { loadGLB } from './MeshLoader';
import { playSound } from './SoundManager';
export class Sword extends Weapon {
private mixer: THREE.AnimationMixer | null = null;
private clips: Map<string, THREE.AnimationAction> = new Map();
constructor() {
super();
this.COOLDOWN1_TIME = 0.5;
this.mesh.position.set(-0.35, 0.3, 0.35);
this.mesh.scale.set(0.2, 0.2, 0.2);
this.mesh.rotation.set(0.3, 0, 0.3);
// Fallback geometry
const bladeGeom = new THREE.BoxGeometry(0.08, 1.0, 0.15);
const handleGeom = new THREE.CylinderGeometry(0.05, 0.05, 0.2, 8);
const guardGeom = new THREE.BoxGeometry(0.25, 0.06, 0.06);
const metalMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.9, roughness: 0.3 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x444444, roughness: 0.6 });
const blade = new THREE.Mesh(bladeGeom, metalMat);
blade.position.y = 0.4;
blade.castShadow = true;
const guard = new THREE.Mesh(guardGeom, metalMat);
guard.position.y = -0.1;
const handle = new THREE.Mesh(handleGeom, darkMat);
handle.position.y = -0.25;
this.mesh.add(blade);
this.mesh.add(guard);
this.mesh.add(handle);
this._fallback = true;
}
async init() {
try {
const gltf = await loadGLB('sword');
const model = gltf.scene;
model.scale.set(1, 1, 1);
const swordTexture = new THREE.TextureLoader().load('./textures/sword.png');
swordTexture.flipY = false;
swordTexture.colorSpace = THREE.SRGBColorSpace;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.material = new THREE.MeshToonMaterial({ map: swordTexture });
}
});
// Remove fallback geometry
while (this.mesh.children.length > 0) {
this.mesh.remove(this.mesh.children[0]);
}
this._fallback = false;
this.mesh.add(model);
if (gltf.animations.length > 0) {
this.mixer = new THREE.AnimationMixer(model);
for (const clip of gltf.animations) {
const action = this.mixer.clipAction(clip);
this.clips.set(clip.name, action);
}
const normal = this.clips.get('normal');
if (normal) {
normal.play();
}
}
} catch (e) {
console.warn('Failed to load sword model', e);
}
}
private _fallback = false;
updateAnimation(dt: number) {
if (this.mixer) this.mixer.update(dt);
}
skill1(_groundPoint: THREE.Vector3, player: Player): void {
if (this.cooldown1 > 0) return;
this.cooldown1 = this.COOLDOWN1_TIME;
playSound('sword_hit1', 0.5);
// Play skill1 animation
const action = this.clips.get('skill1');
if (action) {
action.reset();
action.setLoop(THREE.LoopOnce, 1);
action.setEffectiveTimeScale(2);
action.play();
}
const { game } = player;
for (const enemy of game.enemies) {
if (enemy.dead) continue;
const dist = enemy.body.position.distanceTo(player.body.position);
if (dist < 3) {
const toEnemy = new THREE.Vector3()
.subVectors(enemy.body.position, player.body.position).normalize();
const playerFwd = new THREE.Vector3(0, 0, 1);
playerFwd.applyQuaternion(player.body.quaternion);
const dot = playerFwd.dot(toEnemy);
if (dot > 0.3) {
enemy.takeDamage(50, game);
}
}
}
}
skill2(): void {
// Not implemented
}
}
+25
View File
@@ -0,0 +1,25 @@
import * as THREE from 'three';
import { Player } from './Player';
export abstract class Weapon {
mesh: THREE.Group;
cooldown1 = 0;
cooldown2 = 0;
cooldown3 = 0;
COOLDOWN1_TIME = 0;
COOLDOWN2_TIME = 0;
COOLDOWN3_TIME = 0;
constructor() {
this.mesh = new THREE.Group();
}
reduceCooldowns(tpf: number) {
if (this.cooldown1 > 0) this.cooldown1 -= tpf;
if (this.cooldown2 > 0) this.cooldown2 -= tpf;
if (this.cooldown3 > 0) this.cooldown3 -= tpf;
}
abstract skill1(groundPoint: THREE.Vector3, player: Player): void;
abstract skill2(groundPoint: THREE.Vector3, player: Player): void;
}
+9
View File
@@ -0,0 +1,9 @@
import { Game } from './Game';
async function main() {
const game = new Game();
await game.init();
game.start();
}
main();
+8
View File
@@ -0,0 +1,8 @@
declare module '*.glb' {
const src: string;
export default src;
}
declare module '*.gltf' {
const src: string;
export default src;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
build: {
outDir: 'dist',
assetsDir: 'assets',
},
server: {
host: true,
port: 3000,
},
});