Files
web-adventure/index.html
T
2026-05-29 11:11:44 +02:00

1121 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Adventure - Community RPG</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background: #1a1a1a;
color: #fff;
overflow: hidden;
}
.login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-form {
background: #2a2a2a;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
text-align: center;
}
.login-form h1 {
margin-bottom: 30px;
color: #667eea;
font-size: 2.5em;
}
.login-form input,
.login-form select {
display: block;
width: 100%;
padding: 12px;
margin: 15px 0;
border: none;
border-radius: 5px;
background: #3a3a3a;
color: #fff;
font-size: 1em;
}
.login-form input::placeholder {
color: #999;
}
.color-selector {
display: flex;
gap: 10px;
margin: 20px 0;
flex-wrap: wrap;
}
.color-option {
width: 40px;
height: 40px;
border-radius: 5px;
cursor: pointer;
border: 3px solid transparent;
transition: all 0.3s;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border-color: #fff;
transform: scale(1.2);
}
.login-form button {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
margin-top: 20px;
transition: background 0.3s;
}
.login-form button:hover {
background: #764ba2;
}
.game-screen {
display: none;
width: 100%;
height: 100vh;
position: relative;
}
.game-screen.active {
display: flex;
}
#gameCanvas {
flex: 1;
background: #000;
}
.ui-panel {
position: absolute;
background: rgba(0, 0, 0, 0.9);
color: #fff;
border: 2px solid #667eea;
border-radius: 5px;
padding: 15px;
font-size: 0.9em;
font-family: monospace;
}
.ui-top-left {
top: 10px;
left: 10px;
max-width: 300px;
}
.ui-top-right {
top: 10px;
right: 10px;
text-align: right;
max-width: 300px;
}
.stats {
margin-bottom: 15px;
}
.stat-row {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.stat-label {
color: #aaa;
}
.stat-value {
color: #667eea;
font-weight: bold;
}
.stat-bar {
width: 100%;
height: 20px;
background: #333;
border-radius: 3px;
margin-top: 3px;
overflow: hidden;
}
.stat-bar-fill {
height: 100%;
background: #667eea;
transition: width 0.3s;
}
.stat-bar-fill.health {
background: #ff4444;
}
.stat-bar-fill.action {
background: #44ff44;
}
.inventory {
margin-top: 20px;
border-top: 1px solid #667eea;
padding-top: 10px;
}
.inventory-item {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.bottom-panel {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
width: min(760px, calc(100% - 20px));
background: rgba(0, 0, 0, 0.9);
border: 2px solid #667eea;
border-radius: 5px;
padding: 10px;
}
.button-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
gap: 6px;
}
.action-button {
padding: 6px 8px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
font-size: 0.78em;
line-height: 1.1;
}
.action-button:hover {
background: #764ba2;
transform: scale(1.05);
}
.action-button:disabled {
background: #444;
cursor: not-allowed;
opacity: 0.5;
}
.status-message {
margin-top: 8px;
padding: 8px;
background: #333;
border-radius: 5px;
min-height: 24px;
font-size: 0.85em;
}
.controls-help {
color: #aaa;
font-size: 0.72em;
margin-bottom: 8px;
}
.boss-bar-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
border: 2px solid #ff4444;
border-radius: 5px;
padding: 20px;
text-align: center;
display: none;
z-index: 10;
}
.boss-bar-container.active {
display: block;
}
.game-over-screen {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
align-items: center;
justify-content: center;
}
.game-over-screen.active {
display: flex;
}
.game-over-content {
background: rgba(102, 126, 234, 0.1);
border: 2px solid #667eea;
border-radius: 10px;
padding: 40px;
text-align: center;
max-width: 500px;
}
.game-over-content h1 {
font-size: 2.5em;
margin-bottom: 20px;
}
.game-over-content p {
margin: 10px 0;
font-size: 1.1em;
}
.game-over-content button {
margin-top: 20px;
padding: 12px 30px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
}
/* Camera controls widget */
.camera-controls {
position: absolute;
bottom: 180px;
right: 10px;
background: rgba(0,0,0,0.85);
border: 2px solid #667eea;
border-radius: 8px;
padding: 10px;
display: none;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 20;
user-select: none;
}
.camera-controls.active { display: flex; }
.camera-controls .cam-label {
color: #667eea;
font-size: 0.75em;
font-weight: bold;
margin-bottom: 4px;
letter-spacing: 0.05em;
}
.cam-row { display: flex; gap: 4px; }
.cam-btn {
width: 36px; height: 36px;
background: #667eea;
color: #fff;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.cam-btn:hover { background: #764ba2; }
.cam-btn:active { background: #4a3a8a; transform: scale(0.95); }
.cam-btn.wide { width: 78px; font-size: 0.75em; }
</style>
</head>
<body>
<div class="login-screen" id="loginScreen">
<div class="login-form">
<h1>🎮 Web Adventure</h1>
<form id="loginForm">
<input type="text" id="username" placeholder="Enter your username" required>
<div style="margin: 20px 0; color: #aaa;">Choose your color:</div>
<div class="color-selector" id="colorSelector"></div>
<button type="submit">Enter the World</button>
</form>
<p style="margin-top: 20px; color: #999; font-size: 0.9em;">
Welcome to Web Adventure! Log in to join the multiplayer game world.
</p>
</div>
</div>
<div class="game-screen" id="gameScreen">
<canvas id="gameCanvas"></canvas>
<div class="ui-panel ui-top-left">
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Player Stats</div>
<div class="stats">
<div class="stat-row">
<span class="stat-label">Level:</span>
<span class="stat-value" id="statLevel">1</span>
</div>
<div class="stat-row">
<span class="stat-label">Experience:</span>
<span class="stat-value" id="statExp">0</span>
</div>
<div class="stat-row">
<span class="stat-label">Health:</span>
<span class="stat-value" id="statHealth">100/100</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill health" id="healthBar" style="width: 100%"></div>
</div>
<div class="stat-row" style="margin-top: 10px;">
<span class="stat-label">Action Points:</span>
<span class="stat-value" id="statActionPoints">20</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill action" id="actionBar" style="width: 100%"></div>
</div>
<div class="stat-row" style="margin-top: 10px;">
<span class="stat-label">Attack:</span>
<span class="stat-value" id="statAttack">5</span>
</div>
<div class="stat-row">
<span class="stat-label">Defense:</span>
<span class="stat-value" id="statDefense">2</span>
</div>
<div class="stat-row">
<span class="stat-label">Move Range:</span>
<span class="stat-value" id="statMove">5</span>
</div>
</div>
<div class="inventory">
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Inventory</div>
<div class="inventory-item">
<span>Wood:</span>
<span id="invWood">0</span>
</div>
<div class="inventory-item">
<span>Stone:</span>
<span id="invStone">0</span>
</div>
</div>
</div>
<div class="ui-panel ui-top-right">
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Game Status</div>
<div class="stat-row">
<span class="stat-label">Time Remaining:</span>
<span class="stat-value" id="timeRemaining">30:00</span>
</div>
<div class="stat-row" style="margin-top: 10px;">
<span class="stat-label">Players Online:</span>
<span class="stat-value" id="playersOnline">1</span>
</div>
<div class="stat-row">
<span class="stat-label">Monsters:</span>
<span class="stat-value" id="monsterCount">0</span>
</div>
</div>
<div class="boss-bar-container" id="bossBar">
<div style="color: #ff4444; font-weight: bold; margin-bottom: 10px;">⚔️ BOSS APPEARED ⚔️</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="bossHealthBar" style="width: 100%; background: #ff4444;"></div>
</div>
<div style="margin-top: 10px;">
<span id="bossHealth">Loading...</span>
</div>
</div>
<!-- Camera orbit / zoom controls (top-right, shown after login) -->
<div class="camera-controls" id="cameraControls">
<div class="cam-label">📷 CAMERA</div>
<div class="cam-row">
<button class="cam-btn" id="camUp" title="Tilt up">▲</button>
</div>
<div class="cam-row">
<button class="cam-btn" id="camLeft" title="Orbit left">◀</button>
<button class="cam-btn" id="camCenter" title="Re-center on player">⊙</button>
<button class="cam-btn" id="camRight" title="Orbit right">▶</button>
</div>
<div class="cam-row">
<button class="cam-btn" id="camDown" title="Tilt down">▼</button>
</div>
<div class="cam-row" style="margin-top:4px; gap:4px;">
<button class="cam-btn wide" id="camZoomIn" title="Zoom in">🔍 +</button>
<button class="cam-btn wide" id="camZoomOut" title="Zoom out">🔍 −</button>
</div>
</div>
<div class="bottom-panel">
<div style="color: #667eea; font-weight: bold; margin-bottom: 10px;">Controls</div>
<div class="controls-help">Each action costs 1 AP. Hover buttons to see details.</div>
<div class="button-group">
<button class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button>
<button class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button>
<button class="action-button" id="moveLeftBtn" title="Move west by 1 tile (cost: 1 AP)">⬅ W</button>
<button class="action-button" id="moveRightBtn" title="Move east by 1 tile (cost: 1 AP)">➡ E</button>
</div>
<div class="button-group" style="margin-top: 10px;">
<button class="action-button" id="gatherBtn" title="Gather nearby trees/mountains within radius 5 (cost: 1 AP)">🌳 Gather</button>
<button class="action-button" id="attackBtn" title="Attack nearest selected enemy target (cost: 1 AP on success)">⚔️ Attack</button>
</div>
<div class="button-group" style="margin-top: 10px;">
<button class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)">🏠 House</button>
<button class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)">🌾 Farm</button>
<button class="action-button" id="buildTowerBtn" title="Build Guard Tower (30 wood, 20 stone)">🛡️ Tower</button>
</div>
<div class="status-message" id="statusMessage">Ready for adventure!</div>
</div>
</div>
<div class="game-over-screen" id="gameOverScreen">
<div class="game-over-content">
<h1 id="gameOverTitle">GAME OVER</h1>
<p id="gameOverMessage">The timer has run out!</p>
<p id="gameOverStats"></p>
<button onclick="location.reload()">Return to Login</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
// ==================== GLOBAL STATE ====================
let currentPlayer = null;
let currentPlayerId = null;
let gameState = null;
let gameActive = true;
let ws = null;
let cameraKeyBindingsAttached = false;
const API_BASE = 'http://localhost:8000/api';
// ==================== UTILITIES ====================
function showMessage(msg) {
document.getElementById('statusMessage').textContent = msg;
}
function updateUI() {
if (!currentPlayer) return;
document.getElementById('statLevel').textContent = currentPlayer.level;
document.getElementById('statExp').textContent = currentPlayer.exp;
document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`;
document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`;
document.getElementById('statActionPoints').textContent = currentPlayer.action_points;
document.getElementById('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`;
document.getElementById('statAttack').textContent = currentPlayer.attack;
document.getElementById('statDefense').textContent = currentPlayer.defense;
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
document.getElementById('invWood').textContent = currentPlayer.inventory.wood || 0;
document.getElementById('invStone').textContent = currentPlayer.inventory.stone || 0;
if (gameState) {
const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length;
document.getElementById('playersOnline').textContent = activePlayerCount;
document.getElementById('monsterCount').textContent = Object.keys(gameState.monsters).length;
}
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// ==================== THREE.JS SCENE ====================
let scene, camera, renderer, controls;
const playerSpheres = {};
const monsterCubes = {};
let ossBossObject = null;
const structureObjects = {};
const resourceObjects = {};
function initScene() {
const canvas = document.getElementById('gameCanvas');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
camera.position.set(0, 50, 50);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
renderer.shadowMap.enabled = true;
// Mouse camera controls: left drag to orbit, wheel to zoom, right drag to pan.
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
controls.maxPolarAngle = Math.PI * 0.49;
controls.minDistance = 10;
controls.maxDistance = 200;
controls.enableKeys = false; // We handle arrow keys ourselves.
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 100, 100);
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.castShadow = true;
scene.add(directionalLight);
// Ground plane
const groundGeometry = new THREE.PlaneGeometry(500, 500);
const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x1a3a1a });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Grid helper
const gridHelper = new THREE.GridHelper(500, 50, 0x444444, 0x222222);
gridHelper.position.y = 0.1;
scene.add(gridHelper);
window.addEventListener('resize', () => {
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
});
animate();
}
function focusCameraOnPlayer(force = false) {
if (!currentPlayer || !camera) return;
const targetX = currentPlayer.position.x;
const targetZ = currentPlayer.position.y;
if (force) {
camera.position.set(targetX + 4, 8, targetZ + 6);
if (controls) {
controls.target.set(targetX, 0, targetZ);
controls.minDistance = 3;
controls.maxDistance = 80;
controls.update();
} else {
camera.lookAt(targetX, 0, targetZ);
}
return;
}
if (controls) {
const dx = targetX - controls.target.x;
const dz = targetZ - controls.target.z;
controls.target.x += dx * 0.12;
controls.target.z += dz * 0.12;
} else {
camera.position.x = targetX;
camera.position.z = targetZ + 15;
camera.lookAt(targetX, 0, targetZ);
}
}
function rotateCameraBy(deltaAzimuth, deltaPolar, deltaZoom = 0) {
if (!camera) return;
const target = controls ? controls.target.clone() : new THREE.Vector3(0, 0, 0);
const offset = camera.position.clone().sub(target);
const spherical = new THREE.Spherical().setFromVector3(offset);
spherical.theta -= deltaAzimuth;
spherical.phi = Math.max(0.15, Math.min(Math.PI * 0.48, spherical.phi + deltaPolar));
spherical.radius = Math.max(10, Math.min(200, spherical.radius + deltaZoom));
offset.setFromSpherical(spherical);
camera.position.copy(target).add(offset);
camera.lookAt(target.x, target.y, target.z);
if (controls) {
controls.target.copy(target);
const damp = controls.enableDamping;
controls.enableDamping = false;
controls.update();
controls.enableDamping = damp;
}
}
function panCameraBy(deltaRight, deltaForward) {
if (!camera || !controls) return;
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
if (forward.lengthSq() === 0) return;
forward.normalize();
const right = new THREE.Vector3(forward.z, 0, -forward.x).normalize();
const move = new THREE.Vector3();
move.addScaledVector(right, deltaRight);
move.addScaledVector(forward, deltaForward);
controls.target.add(move);
camera.position.add(move);
controls.update();
}
function createPlayerSphere(player) {
const geometry = new THREE.SphereGeometry(0.5, 16, 16);
const isCurrentPlayer = player.id === currentPlayerId;
const material = new THREE.MeshPhongMaterial({
color: player.color,
emissive: isCurrentPlayer ? 0x222222 : 0x000000,
shininess: isCurrentPlayer ? 80 : 30,
});
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(player.position.x, 0.5, player.position.y);
if (isCurrentPlayer) {
sphere.scale.set(1.15, 1.15, 1.15);
}
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);
// Add label
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 128;
ctx.fillStyle = 'white';
ctx.font = '32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(player.username, 128, 64);
const texture = new THREE.CanvasTexture(canvas);
const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(4, 2, 1);
sprite.position.set(player.position.x, 2.5, player.position.y);
scene.add(sprite);
playerSpheres[player.id] = { mesh: sphere, sprite };
}
function updatePlayerSphere(player) {
if (playerSpheres[player.id]) {
playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y);
playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y);
}
}
function createMonsterCube(monster) {
const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
const material = new THREE.MeshPhongMaterial({ color: 0xff4444 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(monster.position.x, 0.3, monster.position.y);
cube.castShadow = true;
scene.add(cube);
monsterCubes[monster.id] = cube;
}
function createBossMesh(monster) {
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const material = new THREE.MeshPhongMaterial({ color: 0xff0000 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(monster.position.x, 0.75, monster.position.y);
cube.castShadow = true;
scene.add(cube);
ossBossObject = cube;
}
function createStructureMesh(structure) {
let geometry, color;
if (structure.structure_type === 'house') {
geometry = new THREE.ConeGeometry(0.5, 1, 4);
color = 0xc0a080;
} else if (structure.structure_type === 'farm') {
geometry = new THREE.ConeGeometry(0.5, 1, 4);
color = 0x90ee90;
} else if (structure.structure_type === 'guard_tower') {
geometry = new THREE.ConeGeometry(0.35, 1.4, 4);
color = 0x808080;
}
const material = new THREE.MeshPhongMaterial({ color });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(structure.position.x, 0.5, structure.position.y);
mesh.castShadow = true;
scene.add(mesh);
structureObjects[structure.id] = mesh;
}
function createResourceMesh(resource) {
let mesh;
if (resource.resource_type === 'tree') {
const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 0.8, 8);
const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8B4513 });
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 0.4;
const foliageMat = new THREE.MeshPhongMaterial({ color: 0x228b22 });
const foliage1 = new THREE.Mesh(new THREE.ConeGeometry(0.85, 1.3, 8), foliageMat);
foliage1.position.y = 1.3;
const foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.0, 8), foliageMat);
foliage2.position.y = 2.1;
mesh = new THREE.Group();
mesh.add(trunk, foliage1, foliage2);
} else {
const rockMat = new THREE.MeshPhongMaterial({ color: 0x8a8a8a, flatShading: true });
const main = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), rockMat);
main.position.y = 0.5;
main.rotation.y = Math.random() * Math.PI;
const side1 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.38, 0), rockMat);
side1.position.set(0.6, 0.25, 0.12);
side1.rotation.y = Math.random() * Math.PI;
const side2 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28, 0), rockMat);
side2.position.set(-0.5, 0.2, 0.25);
side2.rotation.y = Math.random() * Math.PI;
mesh = new THREE.Group();
mesh.add(main, side1, side2);
}
mesh.position.set(resource.position.x, 0, resource.position.y);
mesh.castShadow = true;
scene.add(mesh);
resourceObjects[resource.id] = mesh;
}
function updateGameScene() {
if (!gameState) return;
// Update players
for (const player of Object.values(gameState.players || {})) {
if (!playerSpheres[player.id]) {
createPlayerSphere(player);
} else {
updatePlayerSphere(player);
}
}
// Remove deleted players
for (const pid in playerSpheres) {
if (!(gameState.players || {})[pid]) {
scene.remove(playerSpheres[pid].mesh);
scene.remove(playerSpheres[pid].sprite);
delete playerSpheres[pid];
}
}
// Update monsters
for (const monster of Object.values(gameState.monsters || {})) {
if (!monsterCubes[monster.id]) {
createMonsterCube(monster);
} else {
monsterCubes[monster.id].position.set(monster.position.x, 0.3, monster.position.y);
}
}
// Remove deleted monsters
for (const mid in monsterCubes) {
if (!(gameState.monsters || {})[mid]) {
scene.remove(monsterCubes[mid]);
delete monsterCubes[mid];
}
}
// Update boss
if (gameState.boss) {
if (!ossBossObject) {
createBossMesh(gameState.boss);
document.getElementById('bossBar').classList.add('active');
} else {
ossBossObject.position.set(gameState.boss.position.x, 0.75, gameState.boss.position.y);
ossBossObject.rotation.y += 0.01;
}
document.getElementById('bossHealthBar').style.width = `${gameState.boss.boss_progress}%`;
document.getElementById('bossHealth').textContent = `${gameState.boss.health} / ${gameState.boss.max_health}`;
} else {
if (ossBossObject) { scene.remove(ossBossObject); ossBossObject = null; }
document.getElementById('bossBar').classList.remove('active');
}
// Update structures
for (const structure of Object.values(gameState.structures || {})) {
if (!structureObjects[structure.id]) createStructureMesh(structure);
}
// Update resources — guard against missing field
const resources = gameState.resources || {};
for (const resource of Object.values(resources)) {
if (!resourceObjects[resource.id]) createResourceMesh(resource);
}
for (const rid in resourceObjects) {
if (!resources[rid]) { scene.remove(resourceObjects[rid]); delete resourceObjects[rid]; }
}
focusCameraOnPlayer(false);
}
function animate() {
requestAnimationFrame(animate);
updateGameScene();
if (controls) controls.update();
renderer.render(scene, camera);
}
// ==================== API FUNCTIONS ====================
async function login(username, color) {
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, color }),
});
const data = await response.json();
currentPlayer = data.player;
currentPlayerId = data.player_id;
gameActive = data.game_active;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('gameScreen').classList.add('active');
initScene();
createPlayerSphere(currentPlayer);
focusCameraOnPlayer(true);
connectWebSocket();
setupControls();
return data;
} catch (error) {
console.error('Login failed:', error);
showMessage('Login failed. Try again.');
}
}
function connectWebSocket() {
ws = new WebSocket(`ws://localhost:8000/ws/${currentPlayerId}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'state_update') {
gameState = data;
currentPlayer = gameState.players[currentPlayerId] || currentPlayer;
document.getElementById('timeRemaining').textContent = formatTime(data.time_remaining);
if (data.time_remaining <= 0) {
showGameOver();
}
updateUI();
} else if (data.type === 'game_over') {
showGameOver();
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket closed');
};
}
function showGameOver() {
gameActive = false;
document.getElementById('gameOverScreen').classList.add('active');
document.getElementById('gameOverMessage').textContent =
'The world resets... Come back when you\'re stronger!';
document.getElementById('gameOverStats').textContent =
`Final Level: ${currentPlayer.level} | Final Experience: ${currentPlayer.exp}`;
}
async function movePlayer(dx, dy) {
try {
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dx, dy }),
});
const data = await response.json();
if (!response.ok) {
showMessage(`Move failed: ${data.detail || data.reason || 'Unknown error'}`);
return data;
}
// Apply local update immediately so movement feels responsive even before WS tick arrives.
if (response.ok && data.position && currentPlayer) {
currentPlayer.position = data.position;
currentPlayer.action_points = data.action_points ?? currentPlayer.action_points;
updatePlayerSphere(currentPlayer);
focusCameraOnPlayer(false);
updateUI();
}
return data;
} catch (error) {
console.error('Move failed:', error);
showMessage('Move failed!');
}
}
async function performAction(actionType, targetId = null, tx = null, ty = null, structureType = null) {
try {
const response = await fetch(`${API_BASE}/player/${currentPlayerId}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action_type: actionType, target_id: targetId, tx, ty, structure_type: structureType }),
});
const data = await response.json();
if (data.success) {
showMessage(`Action successful: ${actionType}`);
} else {
showMessage(`Action failed: ${data.reason || 'Unknown error'}`);
}
return data;
} catch (error) {
console.error('Action failed:', error);
showMessage('Action failed!');
}
}
// ==================== UI CONTROLS ====================
function setupControls() {
document.getElementById('moveUpBtn').onclick = () => movePlayer(0, -1);
document.getElementById('moveDownBtn').onclick = () => movePlayer(0, 1);
document.getElementById('moveLeftBtn').onclick = () => movePlayer(-1, 0);
document.getElementById('moveRightBtn').onclick = () => movePlayer( 1, 0);
// Show and wire camera widget buttons.
document.getElementById('cameraControls').classList.add('active');
const AZ = 0.25, PL = 0.12, ZM = 8;
// Support both click and held-down via a repeating interval.
function bindCamBtn(id, az, pl, zm = 0) {
const btn = document.getElementById(id);
let interval = null;
const fire = () => rotateCameraBy(az, pl, zm);
btn.addEventListener('mousedown', () => { fire(); interval = setInterval(fire, 80); });
btn.addEventListener('touchstart', (e) => { e.preventDefault(); fire(); interval = setInterval(fire, 80); }, { passive: false });
const stop = () => clearInterval(interval);
btn.addEventListener('mouseup', stop);
btn.addEventListener('mouseleave', stop);
btn.addEventListener('touchend', stop);
}
bindCamBtn('camLeft', AZ, 0);
bindCamBtn('camRight', -AZ, 0);
bindCamBtn('camUp', 0, -PL);
bindCamBtn('camDown', 0, PL);
bindCamBtn('camZoomIn', 0, 0, -ZM);
bindCamBtn('camZoomOut', 0, 0, ZM);
document.getElementById('camCenter').onclick = () => focusCameraOnPlayer(true);
if (!cameraKeyBindingsAttached) {
cameraKeyBindingsAttached = true;
document.addEventListener('keydown', (event) => {
const gameVisible = document.getElementById('gameScreen').classList.contains('active');
if (!gameVisible || !camera) return;
const key = event.key.toLowerCase();
const rotationStep = 0.12;
const panStep = 1.0;
if (["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d"].includes(key)) {
event.preventDefault();
}
if (key === 'arrowleft') rotateCameraBy(rotationStep, 0, 0);
else if (key === 'arrowright') rotateCameraBy(-rotationStep, 0, 0);
else if (key === 'arrowup') rotateCameraBy(0, -rotationStep * 0.45, 0);
else if (key === 'arrowdown') rotateCameraBy(0, rotationStep * 0.45, 0);
else if (key === 'z') panCameraBy(0, panStep);
else if (key === 's') panCameraBy(0, -panStep);
else if (key === 'q') panCameraBy(-panStep, 0);
else if (key === 'd') panCameraBy(panStep, 0);
});
}
document.getElementById('attackBtn').onclick = () => {
if (gameState && gameState.boss) {
performAction('attack', gameState.boss.id);
} else if (gameState && Object.keys(gameState.monsters).length > 0) {
const targetId = Object.keys(gameState.monsters)[0];
performAction('attack', targetId);
} else {
showMessage('No targets available');
}
};
document.getElementById('gatherBtn').onclick = () => {
performAction('gather', null);
};
document.getElementById('buildHouseBtn').onclick = () => {
performAction('build', null, currentPlayer.position.x, currentPlayer.position.y, 'house');
};
document.getElementById('buildFarmBtn').onclick = () => {
performAction('build', null, currentPlayer.position.x, currentPlayer.position.y, 'farm');
};
document.getElementById('buildTowerBtn').onclick = () => {
performAction('build', null, currentPlayer.position.x, currentPlayer.position.y, 'guard_tower');
};
}
// ==================== LOGIN FORM ====================
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const color = document.querySelector('.color-option.selected');
if (!color) {
showMessage('Please select a color');
return;
}
await login(username, color.dataset.color);
});
// Color selector
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'];
const colorSelector = document.getElementById('colorSelector');
colors.forEach(color => {
const option = document.createElement('div');
option.className = 'color-option';
option.style.backgroundColor = color;
option.dataset.color = color;
option.onclick = () => {
document.querySelectorAll('.color-option').forEach(c => c.classList.remove('selected'));
option.classList.add('selected');
};
colorSelector.appendChild(option);
});
// Select first color by default
document.querySelector('.color-option').classList.add('selected');
</script>
</body>
</html>