1634 lines
69 KiB
HTML
1634 lines
69 KiB
HTML
<!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: 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-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: 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="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`);
|
||
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</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 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';
|
||
|
||
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.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 (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 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 = 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>
|