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