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
+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);
}
}