Add game source, converter pipeline, and web port
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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":{}}
|
||||
@@ -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":{}}
|
||||
@@ -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":{}}
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 333 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Game } from './Game';
|
||||
|
||||
async function main() {
|
||||
const game = new Game();
|
||||
await game.init();
|
||||
game.start();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,8 @@
|
||||
declare module '*.glb' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gltf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||