408 lines
22 KiB
HTML
408 lines
22 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>Tiny Tycoon 3D (Multiplayer)</title>
|
||
|
|
<style>
|
||
|
|
/* --- General Setup --- */
|
||
|
|
body {
|
||
|
|
margin: 0;
|
||
|
|
overflow: hidden;
|
||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||
|
|
background-color: #1a202c;
|
||
|
|
color: #e2e8f0;
|
||
|
|
}
|
||
|
|
#game-canvas { display: block; }
|
||
|
|
|
||
|
|
/* --- UI Panels --- */
|
||
|
|
.ui-panel {
|
||
|
|
position: absolute;
|
||
|
|
background-color: rgba(45, 55, 72, 0.9);
|
||
|
|
padding: 12px;
|
||
|
|
border-radius: 12px;
|
||
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
|
}
|
||
|
|
#title-card { top: 20px; left: 50%; transform: translateX(-50%); padding: 8px 16px; font-size: 1.25rem; font-weight: bold; }
|
||
|
|
#info-card { top: 20px; right: 20px; max-width: 250px; font-size: 0.875rem; }
|
||
|
|
#info-card h3, #stats-panel h3, #players-panel h3 { font-weight: bold; font-size: 1.125rem; margin-top: 0; margin-bottom: 8px; color: white; }
|
||
|
|
#info-card strong { color: #63b3ed; }
|
||
|
|
#stats-panel { top: 80px; left: 20px; font-size: 1rem; min-width: 240px; }
|
||
|
|
#stats-panel div { margin-bottom: 4px; }
|
||
|
|
#stats-panel span { font-weight: bold; }
|
||
|
|
#money-stat { color: #48bb78; }
|
||
|
|
#population-stat { color: #63b3ed; }
|
||
|
|
#players-panel { top: 200px; left: 20px; max-width: 240px; }
|
||
|
|
#players-list .player-div { padding: 4px; font-size: 0.875rem; }
|
||
|
|
#players-list .player-div.is-self { background-color: rgba(66, 153, 225, 0.5); border-radius: 4px; }
|
||
|
|
#players-list .player-div strong { font-weight: bold; }
|
||
|
|
|
||
|
|
/* --- Build Menu --- */
|
||
|
|
#ui-panel-bottom { bottom: 20px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; }
|
||
|
|
.build-btn {
|
||
|
|
padding: 10px 16px;
|
||
|
|
border: 2px solid transparent;
|
||
|
|
border-radius: 8px;
|
||
|
|
background-color: #4a5568;
|
||
|
|
color: white;
|
||
|
|
font-weight: 600;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.2s ease-in-out;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
font-size: 0.875rem;
|
||
|
|
}
|
||
|
|
.build-btn:hover { background-color: #718096; }
|
||
|
|
.build-btn.active { background-color: #4299e1; border-color: #63b3ed; box-shadow: 0 0 15px rgba(66, 153, 225, 0.5); }
|
||
|
|
.build-btn:disabled { background-color: #2d3748; color: #718096; cursor: not-allowed; }
|
||
|
|
.build-btn svg { width: 20px; height: 20px; }
|
||
|
|
|
||
|
|
/* --- Status Feed --- */
|
||
|
|
#status-feed { bottom: 20px; right: 20px; width: 300px; height: 150px; display: flex; flex-direction: column-reverse; overflow-y: auto; }
|
||
|
|
#status-feed p { margin: 0; padding: 4px 8px; background-color: rgba(0,0,0,0.2); border-radius: 4px; font-size: 0.8rem; margin-top: 4px; }
|
||
|
|
|
||
|
|
/* --- Nickname Modal --- */
|
||
|
|
#nickname-modal { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.8); z-index: 100; display: flex; justify-content: center; align-items: center; }
|
||
|
|
#nickname-form { background-color: #2d3748; padding: 24px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); text-align: center; }
|
||
|
|
#nickname-form h2 { font-size: 1.5rem; font-weight: bold; margin-bottom: 16px; margin-top: 0; color: white; }
|
||
|
|
#nickname-input { background-color: #4a5568; border: 1px solid #718096; color: white; padding: 8px 12px; border-radius: 8px; font-size: 1rem; }
|
||
|
|
#play-btn { background-color: #4299e1; color: white; padding: 10px 20px; border-radius: 8px; font-weight: bold; margin-top: 16px; cursor: pointer; transition: background-color 0.2s; border: none; font-size: 1rem; }
|
||
|
|
#play-btn:hover { background-color: #3182ce; }
|
||
|
|
.hidden { display: none; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div id="nickname-modal">
|
||
|
|
<div id="nickname-form">
|
||
|
|
<h2>Enter Your Nickname</h2>
|
||
|
|
<input type="text" id="nickname-input" placeholder="CityBuilder123" maxlength="15" />
|
||
|
|
<button id="play-btn">Play Game</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div id="game-container" class="hidden">
|
||
|
|
<div id="title-card" class="ui-panel">Tiny Tycoon 3D</div>
|
||
|
|
<div id="info-card" class="ui-panel">
|
||
|
|
<h3>Controls</h3>
|
||
|
|
<p><strong>Left-Click + Drag:</strong> Rotate</p>
|
||
|
|
<p><strong>Right-Click + Drag:</strong> Pan</p>
|
||
|
|
<p><strong>Scroll Wheel:</strong> Zoom</p>
|
||
|
|
</div>
|
||
|
|
<div id="stats-panel" class="ui-panel">
|
||
|
|
<h3>My City</h3>
|
||
|
|
<div>💰 Money: <span id="money-stat"></span></div>
|
||
|
|
<div>👥 Population: <span id="population-stat"></span></div>
|
||
|
|
</div>
|
||
|
|
<div id="players-panel" class="ui-panel">
|
||
|
|
<h3>Players Online</h3>
|
||
|
|
<div id="players-list"></div>
|
||
|
|
</div>
|
||
|
|
<div id="status-feed" class="ui-panel"></div>
|
||
|
|
<div id="ui-panel-bottom" class="ui-panel">
|
||
|
|
<!-- Icons from feathericons.com -->
|
||
|
|
<button class="build-btn" data-type="residential" title="Cost: $100"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>House</button>
|
||
|
|
<button class="build-btn" data-type="commercial" title="Cost: $250"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path><rect x="3" y="10" width="18" height="12" rx="2"></rect><path d="M7 15h.01"></path><path d="M17 15h.01"></path></svg>Shop</button>
|
||
|
|
<button class="build-btn" data-type="industrial" title="Cost: $500"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16"/><path d="M6 18V8.103a2 2 0 0 1 .91-1.657l6-3.79a2 2 0 0 1 2.18 0l6 3.79A2 2 0 0 1 22 8.103V18"/><path d="m14 14-2-1-2 1"/><path d="M18 18h-4v-2h4v2Z"/></svg>Factory</button>
|
||
|
|
<button class="build-btn" data-type="park" title="Cost: $80"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-8"/><path d="m17 8-1.5-1.5c-.9-.9-2.5-.9-3.4 0L12 6.6c-.9-.9-2.5-.9-3.4 0L7 8"/><path d="M12 22h-1a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1Z"/></svg>Park</button>
|
||
|
|
<button class="build-btn" data-type="powerplant" title="Cost: $1000"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v3"/><path d="M16.24 7.76 14.12 9.88"/><path d="m7.76 7.76 2.12 2.12"/><path d="m12 2 3.5 3.5"/><path d="m12 2-3.5 3.5"/><path d="M22 12h-3"/><path d="M5 12H2"/><path d="M19.07 19.07 16.95 16.95"/><path d="m4.93 19.07 2.12-2.12"/><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z"/></svg>Power</button>
|
||
|
|
<button class="build-btn" data-type="road" title="Cost: $20"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/><path d="M4 12v-2a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2"/><path d="M4 12v2a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-2"/></svg>Road</button>
|
||
|
|
<button class="build-btn" data-type="remove" title="Refund: 50%"><svg viewBox="0 0 24 24" fill="none" stroke="#fca5a5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"></path><line x1="18" x2="12" y1="9" y2="15"></line><line x1="12" x2="18" y1="9" y2="15"></line></svg>Remove</button>
|
||
|
|
</div>
|
||
|
|
<canvas id="game-canvas"></canvas>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script type="importmap">
|
||
|
|
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" } }
|
||
|
|
</script>
|
||
|
|
<script type="module">
|
||
|
|
import * as THREE from 'three';
|
||
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
|
|
|
||
|
|
let scene, camera, renderer, controls;
|
||
|
|
let groundPlane, placementIndicator;
|
||
|
|
let websocket, nickname;
|
||
|
|
|
||
|
|
let currentBuildType = null;
|
||
|
|
const gridCellSize = 2;
|
||
|
|
const gridDivisions = 30;
|
||
|
|
const gridSize = gridDivisions * gridCellSize;
|
||
|
|
const buildings = new Map();
|
||
|
|
|
||
|
|
const buildingData = {
|
||
|
|
'residential': { cost: 100 }, 'commercial': { cost: 250 },
|
||
|
|
'industrial': { cost: 500 }, 'park': { cost: 80 },
|
||
|
|
'powerplant': { cost: 1000 }, 'road': { cost: 20 }
|
||
|
|
};
|
||
|
|
|
||
|
|
const nicknameModal = document.getElementById('nickname-modal');
|
||
|
|
const nicknameInput = document.getElementById('nickname-input');
|
||
|
|
const playBtn = document.getElementById('play-btn');
|
||
|
|
|
||
|
|
playBtn.addEventListener('click', () => {
|
||
|
|
nickname = nicknameInput.value.trim();
|
||
|
|
if (nickname && /^[a-zA-Z0-9_]+$/.test(nickname)) {
|
||
|
|
nicknameModal.style.display = 'none';
|
||
|
|
document.getElementById('game-container').classList.remove('hidden');
|
||
|
|
initGame();
|
||
|
|
} else {
|
||
|
|
alert("Please enter a valid nickname (letters, numbers, and underscores only).");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- Core 3D and Game Initialization ---
|
||
|
|
function initGame() {
|
||
|
|
scene = new THREE.Scene();
|
||
|
|
scene.background = new THREE.Color(0x87ceeb);
|
||
|
|
scene.fog = new THREE.Fog(0x87ceeb, 70, 200);
|
||
|
|
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||
|
|
camera.position.set(35, 35, 35);
|
||
|
|
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('game-canvas'), antialias: true });
|
||
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||
|
|
renderer.shadowMap.enabled = true;
|
||
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
||
|
|
controls.enableDamping = true;
|
||
|
|
controls.minPolarAngle = Math.PI / 4;
|
||
|
|
controls.maxPolarAngle = Math.PI / 4;
|
||
|
|
controls.minDistance = 20;
|
||
|
|
controls.maxDistance = 150;
|
||
|
|
|
||
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
|
||
|
|
scene.add(ambientLight);
|
||
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||
|
|
directionalLight.position.set(40, 50, 30);
|
||
|
|
directionalLight.castShadow = true;
|
||
|
|
directionalLight.shadow.mapSize.width = 2048;
|
||
|
|
directionalLight.shadow.mapSize.height = 2048;
|
||
|
|
scene.add(directionalLight);
|
||
|
|
|
||
|
|
groundPlane = new THREE.Mesh(new THREE.PlaneGeometry(gridSize, gridSize), new THREE.MeshLambertMaterial({ color: 0x50c878 }));
|
||
|
|
groundPlane.rotation.x = -Math.PI / 2;
|
||
|
|
groundPlane.receiveShadow = true;
|
||
|
|
scene.add(groundPlane);
|
||
|
|
scene.add(new THREE.GridHelper(gridSize, gridDivisions));
|
||
|
|
|
||
|
|
placementIndicator = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize, 1, gridCellSize), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5, wireframe: true }));
|
||
|
|
placementIndicator.visible = false;
|
||
|
|
scene.add(placementIndicator);
|
||
|
|
|
||
|
|
setupUI();
|
||
|
|
connectWebSocket();
|
||
|
|
animate();
|
||
|
|
|
||
|
|
window.addEventListener('resize', () => {
|
||
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||
|
|
camera.updateProjectionMatrix();
|
||
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }
|
||
|
|
|
||
|
|
// --- WebSocket Logic ---
|
||
|
|
function connectWebSocket() {
|
||
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
|
|
const wsUrl = `${wsProtocol}//${window.location.hostname}:8000/ws/${nickname}`;
|
||
|
|
websocket = new WebSocket(wsUrl);
|
||
|
|
websocket.onmessage = (event) => {
|
||
|
|
const data = JSON.parse(event.data);
|
||
|
|
switch (data.type) {
|
||
|
|
case 'full_state': rebuildCity(data.buildings); break;
|
||
|
|
case 'build_update': createBuilding(data.position, data.building.type, data.key, data.building.owner); break;
|
||
|
|
case 'remove_update': removeBuilding(data.key); break;
|
||
|
|
case 'status_update': addStatusMessage(data.message); break;
|
||
|
|
case 'players_update': updatePlayersList(data.players); break;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
websocket.onclose = () => addStatusMessage("Connection to server lost.");
|
||
|
|
websocket.onerror = () => addStatusMessage("WebSocket connection error.");
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Building and Scene Management ---
|
||
|
|
function rebuildCity(serverBuildings) {
|
||
|
|
buildings.forEach(b => scene.remove(b));
|
||
|
|
buildings.clear();
|
||
|
|
for (const key in serverBuildings) {
|
||
|
|
const [x, z] = key.split('_').map(Number);
|
||
|
|
createBuilding({ x, z }, serverBuildings[key].type, key, serverBuildings[key].owner);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const playerColors = {};
|
||
|
|
function getPlayerColor(ownerNickname) {
|
||
|
|
if (playerColors[ownerNickname]) return playerColors[ownerNickname];
|
||
|
|
let hash = 0;
|
||
|
|
for (let i = 0; i < ownerNickname.length; i++) { hash = ownerNickname.charCodeAt(i) + ((hash << 5) - hash); }
|
||
|
|
const color = (hash & 0x00FFFFFF) | 0x808080;
|
||
|
|
playerColors[ownerNickname] = color;
|
||
|
|
return color;
|
||
|
|
}
|
||
|
|
|
||
|
|
function createBuilding(position, type, key, owner) {
|
||
|
|
if (buildings.has(key)) return;
|
||
|
|
const buildingGroup = new THREE.Group();
|
||
|
|
const mainMaterial = new THREE.MeshLambertMaterial();
|
||
|
|
|
||
|
|
// Set color based on building type for self, or player color for others
|
||
|
|
if (owner !== nickname) {
|
||
|
|
mainMaterial.color.set(getPlayerColor(owner));
|
||
|
|
} else {
|
||
|
|
switch(type) {
|
||
|
|
case 'residential': mainMaterial.color.set(0x4299e1); break;
|
||
|
|
case 'commercial': mainMaterial.color.set(0xf6e05e); break;
|
||
|
|
case 'industrial': mainMaterial.color.set(0xa0aec0); break;
|
||
|
|
case 'park': mainMaterial.color.set(0x48bb78); break;
|
||
|
|
case 'powerplant': mainMaterial.color.set(0xf56565); break;
|
||
|
|
default: mainMaterial.color.set(0xffffff); break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
switch (type) {
|
||
|
|
case 'residential': {
|
||
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize * 0.8, 2.5, gridCellSize * 0.8), mainMaterial);
|
||
|
|
const roof = new THREE.Mesh(new THREE.CylinderGeometry(0, gridCellSize * 0.6, 1.5, 4), new THREE.MeshLambertMaterial({ color: 0x8b4513 }));
|
||
|
|
base.position.y = 1.25; roof.position.y = 3.25; roof.rotation.y = Math.PI / 4;
|
||
|
|
buildingGroup.add(base, roof);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'commercial': {
|
||
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize * 0.9, 4, gridCellSize * 0.9), mainMaterial);
|
||
|
|
base.position.y = 2; buildingGroup.add(base);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'industrial': {
|
||
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize, 2, gridCellSize * 0.9), mainMaterial);
|
||
|
|
const stack = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.3, 4), new THREE.MeshLambertMaterial({color: 0x718096}));
|
||
|
|
base.position.y = 1; stack.position.set(0.6, 2, 0);
|
||
|
|
buildingGroup.add(base, stack);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'park': {
|
||
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize, 0.1, gridCellSize), new THREE.MeshLambertMaterial({color: 0x38a169}));
|
||
|
|
for(let i=0; i<3; i++) {
|
||
|
|
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 1), new THREE.MeshLambertMaterial({color: 0x8b4513}));
|
||
|
|
const leaves = new THREE.Mesh(new THREE.SphereGeometry(0.4), new THREE.MeshLambertMaterial({color: 0x48bb78}));
|
||
|
|
trunk.position.set(Math.random() * 1.4 - 0.7, 0.5, Math.random() * 1.4 - 0.7);
|
||
|
|
leaves.position.y = 1;
|
||
|
|
trunk.add(leaves);
|
||
|
|
buildingGroup.add(trunk);
|
||
|
|
}
|
||
|
|
base.position.y = 0.05; buildingGroup.add(base);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'powerplant': {
|
||
|
|
const base = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize * 0.9, 2, gridCellSize * 0.9), mainMaterial);
|
||
|
|
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.6, 3, 16), new THREE.MeshLambertMaterial({color: 0xe2e8f0}));
|
||
|
|
base.position.y = 1; tower.position.y = 1.5;
|
||
|
|
buildingGroup.add(base, tower);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'road': {
|
||
|
|
const roadSurface = new THREE.Mesh(new THREE.BoxGeometry(gridCellSize, 0.1, gridCellSize), new THREE.MeshLambertMaterial({color: 0x4a5568}));
|
||
|
|
roadSurface.position.y = 0.05; buildingGroup.add(roadSurface);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
buildingGroup.position.set(position.x, 0, position.z);
|
||
|
|
buildingGroup.traverse(c => { if(c.isMesh) c.castShadow = true; });
|
||
|
|
scene.add(buildingGroup);
|
||
|
|
buildings.set(key, buildingGroup);
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeBuilding(key) {
|
||
|
|
if (buildings.has(key)) {
|
||
|
|
const buildingToRemove = buildings.get(key);
|
||
|
|
scene.remove(buildingToRemove);
|
||
|
|
buildingToRemove.traverse(c => { if (c.isMesh) { c.geometry.dispose(); c.material.dispose(); }});
|
||
|
|
buildings.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- UI and Event Handlers ---
|
||
|
|
function setupUI() {
|
||
|
|
document.querySelectorAll('.build-btn').forEach(button => {
|
||
|
|
button.addEventListener('click', () => {
|
||
|
|
const type = button.dataset.type;
|
||
|
|
currentBuildType = (currentBuildType === type) ? null : type;
|
||
|
|
document.querySelectorAll('.build-btn').forEach(btn => btn.classList.toggle('active', btn === button && currentBuildType !== null));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function addStatusMessage(message) {
|
||
|
|
const feed = document.getElementById('status-feed');
|
||
|
|
const p = document.createElement('p');
|
||
|
|
p.textContent = message;
|
||
|
|
feed.prepend(p);
|
||
|
|
if (feed.children.length > 10) feed.lastChild.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
function updatePlayersList(players) {
|
||
|
|
const listDiv = document.getElementById('players-list');
|
||
|
|
listDiv.innerHTML = '';
|
||
|
|
for (const name in players) {
|
||
|
|
const p = players[name];
|
||
|
|
const playerDiv = document.createElement('div');
|
||
|
|
playerDiv.className = 'player-div';
|
||
|
|
if(name === nickname) playerDiv.classList.add('is-self');
|
||
|
|
playerDiv.innerHTML = `<strong style="color: #${getPlayerColor(name).toString(16)}">${name}</strong>: $${Math.floor(p.money)} | Pop: ${p.population}`;
|
||
|
|
listDiv.appendChild(playerDiv);
|
||
|
|
|
||
|
|
if (name === nickname) {
|
||
|
|
document.getElementById('money-stat').textContent = `$${Math.floor(p.money)}`;
|
||
|
|
document.getElementById('population-stat').textContent = p.population;
|
||
|
|
document.querySelectorAll('.build-btn[data-type]').forEach(btn => {
|
||
|
|
const type = btn.dataset.type;
|
||
|
|
if (type !== 'remove' && buildingData[type]) {
|
||
|
|
btn.disabled = p.money < buildingData[type].cost;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const raycaster = new THREE.Raycaster();
|
||
|
|
const mouse = new THREE.Vector2();
|
||
|
|
|
||
|
|
function getGridPosition(event) {
|
||
|
|
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||
|
|
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||
|
|
raycaster.setFromCamera(mouse, camera);
|
||
|
|
const intersects = raycaster.intersectObject(groundPlane);
|
||
|
|
if (intersects.length > 0) {
|
||
|
|
const p = intersects[0].point;
|
||
|
|
return new THREE.Vector3(Math.round(p.x / gridCellSize) * gridCellSize, 0, Math.round(p.z / gridCellSize) * gridCellSize);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('game-canvas').addEventListener('mousemove', (event) => {
|
||
|
|
const gridPos = getGridPosition(event);
|
||
|
|
if (gridPos && currentBuildType) {
|
||
|
|
placementIndicator.position.copy(gridPos);
|
||
|
|
placementIndicator.material.color.set(currentBuildType === 'remove' ? 0xff0000 : 0xffffff);
|
||
|
|
placementIndicator.visible = true;
|
||
|
|
} else {
|
||
|
|
placementIndicator.visible = false;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById('game-canvas').addEventListener('mousedown', (event) => {
|
||
|
|
if (event.button !== 0 || !currentBuildType) return;
|
||
|
|
const gridPos = getGridPosition(event);
|
||
|
|
if (gridPos && websocket && websocket.readyState === WebSocket.OPEN) {
|
||
|
|
const message = {
|
||
|
|
action: currentBuildType === 'remove' ? 'remove' : 'build',
|
||
|
|
type: currentBuildType,
|
||
|
|
position: { x: gridPos.x, z: gridPos.z }
|
||
|
|
};
|
||
|
|
websocket.send(JSON.stringify(message));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
|
||
|
|
|