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

1229 lines
48 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">Range:</span>
<span class="stat-value" id="statRange">4</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 type="button" class="cam-btn" id="camUp" title="Tilt up">▲</button>
</div>
<div class="cam-row">
<button type="button" class="cam-btn" id="camLeft" title="Orbit left">◀</button>
<button type="button" class="cam-btn" id="camCenter" title="Re-center on player">⊙</button>
<button type="button" class="cam-btn" id="camRight" title="Orbit right">▶</button>
</div>
<div class="cam-row">
<button type="button" class="cam-btn" id="camDown" title="Tilt down">▼</button>
</div>
<div class="cam-row" style="margin-top:4px; gap:4px;">
<button type="button" class="cam-btn wide" id="camZoomIn" title="Zoom in">🔍 +</button>
<button type="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 type="button" class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button>
<button type="button" class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button>
<button type="button" class="action-button" id="moveLeftBtn" title="Move west by 1 tile (cost: 1 AP)">⬅ W</button>
<button type="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 type="button" class="action-button" id="gatherBtn" title="Gather nearby trees/mountains within radius 5 (cost: 1 AP)">🌳 Gather</button>
<button type="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 type="button" class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)">🏠 House</button>
<button type="button" class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)">🌾 Farm</button>
<button type="button" class="action-button" id="buildTowerBtn" title="Build Guard Tower (30 wood, 20 stone)">🛡️ Tower</button>
</div>
<div class="button-group" style="margin-top: 10px;">
<button type="button" class="action-button" id="buildBlockBtn" title="Build Block 1 tile north (cost: 2 wood)">🧱 Block</button>
<button type="button" class="action-button" id="lootBtn" title="Loot nearest chest in range">🗝 Loot</button>
<button type="button" class="action-button" id="equipBtn" title="Equip first available weapon slot">⚒ Equip</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;
let cameraAutoFollow = true;
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.effective_attack ?? currentPlayer.attack;
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
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 = {};
const chestObjects = {};
function initScene() {
const canvas = document.getElementById('gameCanvas');
scene = new THREE.Scene();
// Sky-like vertical gradient background (top light blue -> horizon pale -> lower blue).
const skyCanvas = document.createElement('canvas');
skyCanvas.width = 8;
skyCanvas.height = 512;
const skyCtx = skyCanvas.getContext('2d');
const grad = skyCtx.createLinearGradient(0, 0, 0, skyCanvas.height);
grad.addColorStop(0.0, '#7ecbff');
grad.addColorStop(0.45, '#bfe5ff');
grad.addColorStop(1.0, '#5aa7df');
skyCtx.fillStyle = grad;
skyCtx.fillRect(0, 0, skyCanvas.width, skyCanvas.height);
const skyTexture = new THREE.CanvasTexture(skyCanvas);
scene.background = skyTexture;
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.
// Keep follow mode only until the user takes manual camera control.
controls.addEventListener('start', () => { cameraAutoFollow = false; });
// 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: smaller cells so 1-tile movement matches visible grid steps.
const gridHelper = new THREE.GridHelper(500, 500, 0x4a5d66, 0x2b3b42);
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);
const northGeo = new THREE.ConeGeometry(0.18, 0.6, 8);
const northMat = new THREE.MeshPhongMaterial({ color: player.color });
const northArrow = new THREE.Mesh(northGeo, northMat);
// Rotate so the tip points north (-Z direction in Three.js world space).
northArrow.rotation.x = -Math.PI / 2;
northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
scene.add(northArrow);
playerSpheres[player.id] = { mesh: sphere, sprite, northArrow };
}
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);
if (playerSpheres[player.id].northArrow) {
playerSpheres[player.id].northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8);
}
}
}
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;
} else if (structure.structure_type === 'block') {
geometry = new THREE.BoxGeometry(0.9, 0.9, 0.9);
color = 0x7a5f3e;
}
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 createChestMesh(chest) {
const baseGeo = new THREE.BoxGeometry(0.9, 0.55, 0.7);
const baseMat = new THREE.MeshPhongMaterial({ color: 0x8b5a2b });
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.y = 0.28;
const lidGeo = new THREE.BoxGeometry(0.9, 0.2, 0.7);
const lidMat = new THREE.MeshPhongMaterial({ color: 0xd1a13a });
const lid = new THREE.Mesh(lidGeo, lidMat);
lid.position.y = 0.63;
const group = new THREE.Group();
group.add(base);
if (!chest.opened) group.add(lid);
group.position.set(chest.position.x, 0, chest.position.y);
scene.add(group);
chestObjects[chest.id] = group;
}
function createResourceMesh(resource) {
const isTree = resource.resource_type === 'tree';
const geometry = isTree
? new THREE.ConeGeometry(0.45, 1.1, 8)
: new THREE.BoxGeometry(0.9, 0.75, 0.9);
const material = new THREE.MeshPhongMaterial({
color: isTree ? 0x2e8b57 : 0x6f7b86,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(resource.position.x, isTree ? 0.55 : 0.38, resource.position.y);
mesh.castShadow = true;
mesh.receiveShadow = 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);
if (playerSpheres[pid].northArrow) scene.remove(playerSpheres[pid].northArrow);
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]; }
}
const chests = gameState.chests || {};
for (const chest of Object.values(chests)) {
if (!chestObjects[chest.id]) createChestMesh(chest);
}
for (const cid in chestObjects) {
if (!chests[cid]) {
scene.remove(chestObjects[cid]);
delete chestObjects[cid];
}
}
if (cameraAutoFollow) {
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, slotIndex = 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, slot_index: slotIndex }),
});
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 = () => {
cameraAutoFollow = false;
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 = () => {
cameraAutoFollow = true;
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();
cameraAutoFollow = false;
}
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 || !currentPlayer) {
showMessage('No targets available');
return;
}
const range = currentPlayer.attack_range || 4;
const candidates = [];
if (gameState.boss) candidates.push(gameState.boss);
for (const m of Object.values(gameState.monsters || {})) candidates.push(m);
for (const s of Object.values(gameState.structures || {})) {
if (s.structure_type === 'block') candidates.push(s);
}
let nearest = null;
let nearestDist = Infinity;
for (const target of candidates) {
const dist = Math.hypot(target.position.x - currentPlayer.position.x, target.position.y - currentPlayer.position.y);
if (dist <= range && dist < nearestDist) {
nearestDist = dist;
nearest = target;
}
}
if (!nearest) {
showMessage(`No target in range (${range.toFixed(1)})`);
return;
}
performAction('attack', nearest.id);
};
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');
};
document.getElementById('buildBlockBtn').onclick = () => {
performAction('build', null, null, null, 'block');
};
document.getElementById('lootBtn').onclick = async () => {
const data = await performAction('loot');
if (data && data.success && typeof data.slot_index === 'number') {
await performAction('equip', null, null, null, null, data.slot_index);
}
};
document.getElementById('equipBtn').onclick = () => {
if (!currentPlayer || !Array.isArray(currentPlayer.weapon_slots)) {
showMessage('No weapon slots available');
return;
}
const slot = currentPlayer.weapon_slots.findIndex(w => w);
if (slot < 0) {
showMessage('No weapon to equip');
return;
}
performAction('equip', null, null, null, null, slot);
};
}
// ==================== 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>