Files
web-adventure/index.html
T

1695 lines
72 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;
}
.stat-bar-fill.exp {
background: #f1c40f;
}
.inv-value { transition: color 0.15s; }
.inv-value.flash { color: #66ff88 !important; }
@keyframes inv-flash {
0% { color: #66ff88; }
100% { color: inherit; }
}
.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: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
border: 2px solid #ff4444;
border-radius: 5px;
padding: 10px 14px;
text-align: center;
display: none;
z-index: 30;
}
.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-bar">
<div class="stat-bar-fill exp" id="expBar" style="width: 0%"></div>
</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 style="margin-top: 8px; border-top: 1px solid #667eea; padding-top: 8px;">
<div style="color: #667eea; font-weight: bold; margin-bottom: 6px;">Nearby Buffs</div>
<div id="activeBuffs" style="font-size:0.8em; color:#bbb; line-height:1.25;">No nearby buffs</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" class="inv-value">0</span>
</div>
<div class="inventory-item">
<span>🪨 Stone:</span>
<span id="invStone" class="inv-value">0</span>
</div>
<div style="color: #667eea; font-weight: bold; margin: 10px 0 6px;">Equipment</div>
<div id="weaponSlots" style="font-size:0.82em; color:#ccc;"></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: 6px;">Boss</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="bossHealthBar" style="width: 100%; background: #ff4444;"></div>
</div>
<div style="margin-top: 6px;">
<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">Keys: Z/Q/S/D move, A attack, E gather, R loot, 1/2/3/4 build. Arrows keep camera orbit.</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="House (+20 max HP bonus nearby) — costs 20 🪵 + 10 🪨">🏠 House (20w 10s)</button>
<button type="button" class="action-button" id="buildFarmBtn" title="Farm (doubles AP regeneration nearby) — costs 15 🪵 + 5 🪨">🌾 Farm (15w 5s)</button>
<button type="button" class="action-button" id="buildTowerBtn" title="Guard Tower (+2 defense bonus nearby) — costs 30 🪵 + 20 🪨">🛡️ Tower (30w 20s)</button>
</div>
<div class="button-group" style="margin-top: 10px;">
<button type="button" class="action-button" id="buildBlockBtn" title="Place an impassable block 1 tile north — costs 2 🪵">🧱 Block (2w)</button>
<button type="button" class="action-button" id="lootBtn" title="Loot nearest chest within range 3 — auto-equips weapon">🗝 Loot</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;
let cameraFollowOnMove = true;
const API_BASE = 'http://localhost:8000/api';
// ==================== UTILITIES ====================
function showMessage(msg) {
document.getElementById('statusMessage').textContent = msg;
}
function flashEl(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 600);
}
function updateUI() {
if (!currentPlayer) return;
document.getElementById('statLevel').textContent = currentPlayer.level;
document.getElementById('statExp').textContent = currentPlayer.exp;
// Calculate XP progress for new level: 30 for level 1, +20 per level after
// XP needed to reach this level: 30 + 20*(level-2) for level >= 2, or 0 for level 1
const currentLevel = currentPlayer.level;
let xpForCurrentLevel = 0;
let xpForNextLevel = 30;
if (currentLevel > 1) {
xpForCurrentLevel = 30 + 20 * (currentLevel - 2);
xpForNextLevel = 30 + 20 * (currentLevel - 1);
}
const xpIntoLevel = currentPlayer.exp - xpForCurrentLevel;
const xpNeededForLevel = xpForNextLevel - xpForCurrentLevel;
const levelProgress = Math.max(0, Math.min(100, (xpIntoLevel / xpNeededForLevel) * 100));
document.getElementById('expBar').style.width = `${levelProgress}%`;
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);
const nearbyBonuses = currentPlayer.nearby_bonuses || {};
const defenseBonus = Number(nearbyBonuses.defense || 0);
const baseDefense = Number(currentPlayer.base_defense ?? currentPlayer.defense ?? 0);
const effectiveDefense = Number(currentPlayer.defense ?? baseDefense);
document.getElementById('statDefense').textContent = defenseBonus > 0
? `${baseDefense} (+${defenseBonus}) = ${effectiveDefense}`
: effectiveDefense;
document.getElementById('statMove').textContent = currentPlayer.movement_capacity;
const buffRows = [];
const actionRegen = Number(nearbyBonuses.action_regen || 0);
const healthBonus = Number(nearbyBonuses.max_health || 0);
const hpRegenBonus = Number(nearbyBonuses.hp_regen || 0);
const freeMoveChance = Number(nearbyBonuses.free_move_chance || 0);
if (actionRegen > 0) buffRows.push(`⚡ Nearby aura: +${actionRegen} AP per regen tick`);
if (defenseBonus > 0) buffRows.push(`🛡️ Nearby aura: +${defenseBonus} defense`);
if (healthBonus > 0) buffRows.push(`❤️ Nearby aura: +${healthBonus} max HP`);
if (hpRegenBonus > 0) buffRows.push(`💚 Nearby aura: +${hpRegenBonus} HP per regen tick`);
if (freeMoveChance > 0) buffRows.push(`👢 Nearby aura: ${Math.round(freeMoveChance * 100)}% chance moves cost 0 AP`);
document.getElementById('activeBuffs').innerHTML = buffRows.length > 0
? buffRows.join('<br>')
: '<span style="color:#555">No nearby buffs</span>';
const newWood = currentPlayer.inventory.wood || 0;
const newStone = currentPlayer.inventory.stone || 0;
const prevWood = parseInt(document.getElementById('invWood').textContent) || 0;
const prevStone = parseInt(document.getElementById('invStone').textContent) || 0;
document.getElementById('invWood').textContent = newWood;
document.getElementById('invStone').textContent = newStone;
if (newWood !== prevWood) flashEl('invWood');
if (newStone !== prevStone) flashEl('invStone');
// Weapon slots
const slots = currentPlayer.weapon_slots || [];
const equippedIdx = currentPlayer.equipped_weapon_slot ?? -1;
const slotEl = document.getElementById('weaponSlots');
if (slotEl) {
const filled = slots.filter(Boolean);
if (filled.length === 0) {
slotEl.innerHTML = '<span style="color:#555">No weapons</span>';
} else {
slotEl.innerHTML = slots.map((w, i) => {
if (!w) return '';
const eq = i === equippedIdx;
const border = eq ? 'border:1px solid #f1c40f' : 'border:1px solid #444';
const bg = eq ? 'background:#2a2600' : 'background:#1e1e1e';
const icon = eq ? '⚔️ ' : '· ';
const aura = w.aura_bonuses || {};
const auraParts = [];
if (aura.action_regen) auraParts.push(`+${aura.action_regen} AP regen`);
if (aura.hp_regen) auraParts.push(`+${aura.hp_regen} HP regen`);
if (aura.defense) auraParts.push(`+${aura.defense} def`);
if (aura.max_health) auraParts.push(`+${aura.max_health} max HP`);
if (aura.free_move_chance) auraParts.push(`${Math.round(aura.free_move_chance * 100)}% free moves`);
if (w.movement_bonus) auraParts.push(`+${w.movement_bonus} move range`);
const auraLine = auraParts.length > 0
? `<div style="color:#7fd6ff; margin-top:2px; pointer-events:none;">Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}</div>`
: '';
return `<div data-slot="${i}" style="padding:3px 6px;margin:2px 0;border-radius:4px;${border};${bg};cursor:pointer;"
title="Click to equip" class="weapon-slot">
${icon}<b>${w.name}</b>
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng${w.movement_bonus ? ` +${w.movement_bonus}mov` : ''}</span>
${auraLine}
</div>`;
}).join('');
}
}
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 = {};
const worldEffects = [];
let previousFrameTime = performance.now();
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(200, 200);
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(200, 200, 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);
// Scale sphere based on level: 1.0 at level 1, increases with level
const levelScale = 1.0 + (player.level - 1) * 0.08;
sphere.scale.set(levelScale, levelScale, levelScale);
if (isCurrentPlayer) {
// Current player slightly larger
sphere.scale.multiplyScalar(1.15);
}
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);
// Compact nameplate — canvas is exactly as tall as the pill so no
// transparent headroom inflates the visible sprite quad.
const nameCvs = document.createElement('canvas');
const nameCtx = nameCvs.getContext('2d');
nameCvs.width = 256;
nameCvs.height = 44; // just enough for one line of 28px text
nameCtx.font = 'bold 28px Arial';
const textWidth = nameCtx.measureText(player.username).width;
const bgWidth = Math.min(nameCvs.width, textWidth + 22);
const bgX = (nameCvs.width - bgWidth) / 2;
drawRoundedRect(nameCtx, bgX, 0, bgWidth, nameCvs.height, 8, 'rgba(0,0,0,0.50)');
nameCtx.fillStyle = '#ffffff';
nameCtx.textAlign = 'center';
nameCtx.textBaseline = 'middle';
nameCtx.fillText(player.username, nameCvs.width / 2, nameCvs.height / 2);
// Scale sprite so world-height matches canvas aspect: width=3.2, height=3.2*(44/256)≈0.55
const texture = new THREE.CanvasTexture(nameCvs);
const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(spriteMaterial);
const spriteW = 3.2;
sprite.scale.set(spriteW, spriteW * (nameCvs.height / nameCvs.width), 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.5, player.position.y - 1.1);
scene.add(northArrow);
playerSpheres[player.id] = { mesh: sphere, sprite, northArrow };
setPlayerVisualState(player);
}
function setPlayerVisualState(player) {
const sphereObj = playerSpheres[player.id];
if (!sphereObj) return;
const isActive = player.active !== false;
const opacity = isActive ? 1.0 : 0.35;
sphereObj.mesh.material.transparent = !isActive;
sphereObj.mesh.material.opacity = opacity;
sphereObj.sprite.material.transparent = !isActive;
sphereObj.sprite.material.opacity = opacity;
if (sphereObj.northArrow) {
sphereObj.northArrow.material.transparent = !isActive;
sphereObj.northArrow.material.opacity = opacity;
}
}
function updatePlayerSphere(player) {
if (playerSpheres[player.id]) {
playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y);
// Update scale based on level
const levelScale = 1.0 + (player.level - 1) * 0.08;
const baseScale = player.id === currentPlayerId ? levelScale * 1.15 : levelScale;
playerSpheres[player.id].mesh.scale.set(baseScale, baseScale, baseScale);
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.5, player.position.y - 1.1);
}
setPlayerVisualState(player);
}
}
function createTextSprite(initialText, textColor = '#ffffff', bgColor = 'rgba(0, 0, 0, 0.7)') {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 96;
const ctx = canvas.getContext('2d');
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(3.5, 1.3, 1);
const spriteObj = { sprite, canvas, ctx, texture, textColor, bgColor };
updateTextSprite(spriteObj, initialText);
return spriteObj;
}
function drawRoundedRect(ctx, x, y, width, height, radius, fillStyle) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fillStyle = fillStyle;
ctx.fill();
}
function createProgressBarSprite() {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 40;
const ctx = canvas.getContext('2d');
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(2.2, 0.35, 1);
return { sprite, canvas, ctx, texture };
}
function updateProgressBarSprite(barObj, ratio, fillColor = '#ff5555') {
const { canvas, ctx, texture } = barObj;
const clampedRatio = Math.max(0, Math.min(1, ratio));
const pad = 4;
const trackX = 12;
const trackY = 10;
const trackW = canvas.width - 24;
const trackH = 20;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawRoundedRect(ctx, trackX - pad, trackY - pad, trackW + pad * 2, trackH + pad * 2, 8, 'rgba(0, 0, 0, 0.65)');
drawRoundedRect(ctx, trackX, trackY, trackW, trackH, 6, '#333333');
drawRoundedRect(ctx, trackX, trackY, trackW * clampedRatio, trackH, 6, fillColor);
texture.needsUpdate = true;
}
function updateTextSprite(spriteObj, text) {
const { canvas, ctx, texture, textColor, bgColor } = spriteObj;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = textColor;
ctx.font = 'bold 30px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
texture.needsUpdate = true;
}
function createMonsterCube(monster) {
const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
// Color by level: green (1-3), yellow (4-6), orange (7-9), red (10+)
let monsterColor = 0xff4444; // red default
if (monster.level <= 3) monsterColor = 0x44ff44; // green
else if (monster.level <= 6) monsterColor = 0xffff44; // yellow
else if (monster.level <= 9) monsterColor = 0xff8844; // orange
else monsterColor = 0xff4444; // red for 10+
const material = new THREE.MeshPhongMaterial({ color: monsterColor });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(monster.position.x, 0.3, monster.position.y);
cube.castShadow = true;
scene.add(cube);
const label = createTextSprite(`Lv ${monster.level} HP ${monster.health}/${monster.max_health}`, '#ffdede');
label.sprite.position.set(monster.position.x, 1.25, monster.position.y);
scene.add(label.sprite);
const hpBar = createProgressBarSprite();
hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y);
updateProgressBarSprite(hpBar, monster.max_health > 0 ? monster.health / monster.max_health : 0);
scene.add(hpBar.sprite);
monsterCubes[monster.id] = { mesh: cube, label, hpBar };
}
function spawnFloatingText(text, worldX, worldZ, color = '#ffffff') {
const textObj = createTextSprite(text, color, 'rgba(0, 0, 0, 0.55)');
textObj.sprite.scale.set(2.5, 0.95, 1);
textObj.sprite.position.set(worldX, 1.4, worldZ);
scene.add(textObj.sprite);
worldEffects.push({
type: 'text',
object: textObj.sprite,
life: 0.9,
maxLife: 0.9,
velocityY: 1.6,
});
}
function spawnGroundPulse(worldX, worldZ, color = 0xffffff) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.22, 0.34, 24),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9, side: THREE.DoubleSide })
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(worldX, 0.06, worldZ);
scene.add(ring);
worldEffects.push({
type: 'pulse',
object: ring,
life: 0.45,
maxLife: 0.45,
growth: 2.2,
});
}
function updateWorldEffects(deltaTime) {
for (let i = worldEffects.length - 1; i >= 0; i--) {
const fx = worldEffects[i];
fx.life -= deltaTime;
if (fx.life <= 0) {
scene.remove(fx.object);
worldEffects.splice(i, 1);
continue;
}
const t = fx.life / fx.maxLife;
if (fx.type === 'text') {
fx.object.position.y += fx.velocityY * deltaTime;
fx.object.material.opacity = t;
} else if (fx.type === 'pulse') {
const scale = 1 + (1 - t) * fx.growth;
fx.object.scale.set(scale, scale, scale);
fx.object.material.opacity = t;
}
}
}
function createBossMesh(monster) {
const group = new THREE.Group();
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(0, 0.75, 0);
cube.castShadow = true;
group.add(cube);
// Vertical light ray so the boss is visible from far away.
const beamGeo = new THREE.CylinderGeometry(0.18, 1.4, 18, 20, 1, true);
const beamMat = new THREE.MeshBasicMaterial({
color: 0xfff2a8,
transparent: true,
opacity: 0.32,
side: THREE.DoubleSide,
depthWrite: false,
});
const beam = new THREE.Mesh(beamGeo, beamMat);
beam.position.set(0, 9, 0);
group.add(beam);
const halo = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.7, 28),
new THREE.MeshBasicMaterial({
color: 0xffe680,
transparent: true,
opacity: 0.38,
side: THREE.DoubleSide,
depthWrite: false,
})
);
halo.rotation.x = -Math.PI / 2;
halo.position.set(0, 0.08, 0);
group.add(halo);
group.position.set(monster.position.x, 0, monster.position.y);
scene.add(group);
ossBossObject = group;
}
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';
let mesh;
if (isTree) {
// Trunk
const trunkGeo = new THREE.CylinderGeometry(0.1, 0.14, 0.55, 7);
const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8b5e3c });
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 0.28;
trunk.castShadow = true;
// Canopy
const canopyGeo = new THREE.ConeGeometry(0.52, 1.2, 8);
const canopyMat = new THREE.MeshPhongMaterial({ color: 0x2e8b57 });
const canopy = new THREE.Mesh(canopyGeo, canopyMat);
canopy.position.y = 1.15; // sits on top of trunk
canopy.castShadow = true;
const group = new THREE.Group();
group.add(trunk);
group.add(canopy);
group.position.set(resource.position.x, 0, resource.position.y);
group.receiveShadow = true;
scene.add(group);
resourceObjects[resource.id] = group;
return;
}
// Mountain / rock
const geometry = new THREE.DodecahedronGeometry(0.52, 0);
const material = new THREE.MeshPhongMaterial({ color: 0x6f7b86 });
mesh = new THREE.Mesh(geometry, material);
mesh.position.set(resource.position.x, 0.52, resource.position.y);
mesh.rotation.set(Math.random() * 0.4, Math.random() * Math.PI, Math.random() * 0.4);
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].mesh.position.set(monster.position.x, 0.3, monster.position.y);
monsterCubes[monster.id].label.sprite.position.set(monster.position.x, 1.25, monster.position.y);
monsterCubes[monster.id].hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y);
updateTextSprite(
monsterCubes[monster.id].label,
`Lv ${monster.level} HP ${Math.max(0, monster.health)}/${monster.max_health}`
);
updateProgressBarSprite(
monsterCubes[monster.id].hpBar,
monster.max_health > 0 ? monster.health / monster.max_health : 0
);
}
}
// Remove deleted monsters
for (const mid in monsterCubes) {
if (!(gameState.monsters || {})[mid]) {
scene.remove(monsterCubes[mid].mesh);
scene.remove(monsterCubes[mid].label.sprite);
scene.remove(monsterCubes[mid].hpBar.sprite);
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, 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 (chest.opened) {
// Remove from scene as soon as it's marked opened
if (chestObjects[chest.id]) {
scene.remove(chestObjects[chest.id]);
delete chestObjects[chest.id];
}
} else 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);
const now = performance.now();
const deltaTime = Math.min((now - previousFrameTime) / 1000, 0.05);
previousFrameTime = now;
updateGameScene();
updateWorldEffects(deltaTime);
if (controls) controls.update();
renderer.render(scene, camera);
}
function followCameraByPlayerDelta(previousPosition, nextPosition) {
if (!camera || !controls || !cameraFollowOnMove || !previousPosition || !nextPosition) return;
const deltaX = (nextPosition.x ?? 0) - (previousPosition.x ?? 0);
const deltaZ = (nextPosition.y ?? 0) - (previousPosition.y ?? 0);
if (Math.abs(deltaX) < 0.0001 && Math.abs(deltaZ) < 0.0001) return;
controls.target.x += deltaX;
controls.target.z += deltaZ;
camera.position.x += deltaX;
camera.position.z += deltaZ;
controls.update();
}
// ==================== 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') {
const previousPosition = currentPlayer ? { ...currentPlayer.position } : null;
gameState = data;
currentPlayer = gameState.players[currentPlayerId] || currentPlayer;
if (currentPlayer && previousPosition) {
followCameraByPlayerDelta(previousPosition, currentPlayer.position);
}
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 previousPosition = currentPlayer ? { ...currentPlayer.position } : null;
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);
followCameraByPlayerDelta(previousPosition, currentPlayer.position);
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'}`);
}
if (data.success && currentPlayer) {
if (typeof data.player_health === 'number') {
currentPlayer.health = data.player_health;
}
if (data.respawn_position) {
currentPlayer.position = data.respawn_position;
updatePlayerSphere(currentPlayer);
focusCameraOnPlayer(true);
}
if (actionType === 'gather' && data.gathered) {
const wood = data.gathered.wood || 0;
const stone = data.gathered.stone || 0;
const parts = [];
if (wood > 0) parts.push(`+${wood} wood`);
if (stone > 0) parts.push(`+${stone} stone`);
if (parts.length > 0) {
spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0x66ff88);
spawnFloatingText(parts.join(' '), currentPlayer.position.x, currentPlayer.position.y, '#9cff9c');
}
}
if (actionType === 'attack') {
if (typeof data.damage === 'number') {
spawnFloatingText(`-${data.damage} HP`, currentPlayer.position.x, currentPlayer.position.y, '#ff9999');
}
if (typeof data.retaliation_damage === 'number' && data.retaliation_damage > 0) {
spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0xff6666);
spawnFloatingText(`Retaliated: -${data.retaliation_damage}`, currentPlayer.position.x, currentPlayer.position.y, '#ff7777');
}
}
}
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 hotkeys = ["arrowleft", "arrowright", "arrowup", "arrowdown", "z", "q", "s", "d", "a", "e", "r", "1", "2", "3", "4"];
if (hotkeys.includes(key)) {
event.preventDefault();
}
// Camera controls stay on arrows only.
if (key === 'arrowleft') {
cameraAutoFollow = false;
rotateCameraBy(rotationStep, 0, 0);
} else if (key === 'arrowright') {
cameraAutoFollow = false;
rotateCameraBy(-rotationStep, 0, 0);
} else if (key === 'arrowup') {
cameraAutoFollow = false;
rotateCameraBy(0, -rotationStep * 0.45, 0);
} else if (key === 'arrowdown') {
cameraAutoFollow = false;
rotateCameraBy(0, rotationStep * 0.45, 0);
// Movement + actions on nearby keys.
} else if (key === 'z') {
document.getElementById('moveUpBtn').click();
} else if (key === 's') {
document.getElementById('moveDownBtn').click();
} else if (key === 'q') {
document.getElementById('moveLeftBtn').click();
} else if (key === 'd') {
document.getElementById('moveRightBtn').click();
} else if (key === 'a') {
document.getElementById('attackBtn').click();
} else if (key === 'e') {
document.getElementById('gatherBtn').click();
} else if (key === 'r') {
document.getElementById('lootBtn').click();
} else if (key === '1') {
document.getElementById('buildHouseBtn').click();
} else if (key === '2') {
document.getElementById('buildFarmBtn').click();
} else if (key === '3') {
document.getElementById('buildTowerBtn').click();
} else if (key === '4') {
document.getElementById('buildBlockBtn').click();
}
});
}
document.getElementById('attackBtn').onclick = async () => {
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;
}
spawnGroundPulse(nearest.position.x, nearest.position.y, 0xffaa66);
const result = await performAction('attack', nearest.id);
if (result && result.success && typeof result.damage === 'number') {
spawnFloatingText(`-${result.damage}`, nearest.position.x, nearest.position.y, '#ffb0b0');
}
};
document.getElementById('gatherBtn').onclick = async () => {
const data = await performAction('gather', null);
if (data && data.success && data.inventory && currentPlayer) {
currentPlayer.inventory = data.inventory;
updateUI();
}
};
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') {
if (data.weapon_slots) currentPlayer.weapon_slots = data.weapon_slots;
const equipData = await performAction('equip', null, null, null, null, data.slot_index);
if (equipData && equipData.success) {
currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot;
showMessage(`Equipped: ${data.weapon ? data.weapon.name : 'weapon'}!`);
}
updateUI();
}
};
// Click a weapon in the inventory panel to equip it (no AP cost).
document.addEventListener('click', async (e) => {
const weaponSlot = e.target.closest('.weapon-slot');
if (!weaponSlot) return;
const slotIndex = parseInt(weaponSlot.getAttribute('data-slot'), 10);
if (!Number.isFinite(slotIndex) || slotIndex < 0) return;
if (!currentPlayer.weapon_slots || !currentPlayer.weapon_slots[slotIndex]) return;
const weaponName = currentPlayer.weapon_slots[slotIndex]?.name || 'weapon';
const equipData = await performAction('equip', null, null, null, null, slotIndex);
if (equipData && equipData.success) {
currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot;
showMessage(`Equipped: ${weaponName}`);
updateUI();
}
}, true); // Use capture phase for maximum reliability
}
// ==================== 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>