27 lines
150 KiB
XML
27 lines
150 KiB
XML
<?xml version="1.0" encoding="UTF-8"?>
|
||
<project version="4">
|
||
<component name="CopilotDiffPersistence">
|
||
<option name="pendingDiffs">
|
||
<map>
|
||
<entry key="$PROJECT_DIR$/backend.py">
|
||
<value>
|
||
<PendingDiffInfo>
|
||
<option name="filePath" value="$PROJECT_DIR$/backend.py" />
|
||
<option name="originalContent" value="import os import json import asyncio from datetime import datetime, timedelta from typing import Dict, List, Set from dataclasses import dataclass, field import random import hashlib from fastapi import FastAPI, WebSocket, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware # Configuration GAME_DURATION = 30 * 60 # 30 minutes in seconds GRID_SIZE = 500 # Grid size in tiles PLAYER_START_SPAWN_RANGE = 50 BOSS_SPAWN_DISTANCE = 150 ACTION_POINTS_MAX = 20 ACTION_POINTS_REGEN_INTERVAL = 60 # 1 minute MONSTER_SPAWN_RATE = 0.1 # Probability per game tick MAX_MONSTERS = 50 BOSS_HEALTH_BASE = 1000 MIN_LEVEL_FOR_BOSS = 10 BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1 # Data Models @dataclass class Position: x: float y: float z: float = 0 def to_dict(self): return {"x": self.x, "y": self.y, "z": self.z} @classmethod def from_dict(cls, d): return cls(x=d["x"], y=d["y"], z=d.get("z", 0)) @dataclass class Player: id: str username: str color: str level: int = 1 exp: int = 0 position: Position = field(default_factory=lambda: Position(0, 0, 0)) health: int = 100 max_health: int = 100 action_points: int = ACTION_POINTS_MAX max_action_points: int = ACTION_POINTS_MAX last_action_regen: float = 0 inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0}) attack: int = 5 defense: int = 2 movement_capacity: int = 1 gathering_capacity: int = 10 active: bool = True session_id: str = "" def to_dict(self): return { "id": self.id, "username": self.username, "color": self.color, "level": self.level, "exp": self.exp, "position": self.position.to_dict(), "health": self.health, "max_health": self.max_health, "action_points": self.action_points, "max_action_points": self.max_action_points, "inventory": self.inventory, "attack": self.attack, "defense": self.defense, "movement_capacity": self.movement_capacity, "gathering_capacity": self.gathering_capacity, "active": self.active, } @dataclass class Monster: id: str position: Position health: int max_health: int level: int attack: int is_boss: bool = False boss_progress: float = 0.0 # Percentage of damage done def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "health": self.health, "max_health": self.max_health, "level": self.level, "attack": self.attack, "is_boss": self.is_boss, "boss_progress": self.boss_progress, } @dataclass class Structure: id: str position: Position structure_type: str # "house", "farm", "guard_tower" owner_id: str health: int bonuses: Dict = field(default_factory=dict) def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "structure_type": self.structure_type, "owner_id": self.owner_id, "health": self.health, "bonuses": self.bonuses, } @dataclass class Resource: id: str position: Position resource_type: str # "tree", "mountain" amount: int def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "resource_type": self.resource_type, "amount": self.amount, } class GameWorld: def __init__(self): self.players: Dict[str, Player] = {} self.monsters: Dict[str, Monster] = {} self.structures: Dict[str, Structure] = {} self.resources: Dict[str, Resource] = {} self.boss: Monster = None self.game_start_time: float = 0 self.is_game_active: bool = False self.connected_sessions: Dict[str, str] = {} # session_id -> player_id self.generation = 0 self._init_resources() def _init_resources(self): """Initialize static resources on the map""" for _ in range(50): resource_id = f"resource_{len(self.resources)}" x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) resource_type = random.choice(["tree", "mountain"]) self.resources[resource_id] = Resource( id=resource_id, position=Position(x, y, 0), resource_type=resource_type, amount=random.randint(50, 200), ) def start_game(self): """Start or restart the game""" self.game_start_time = datetime.now().timestamp() self.is_game_active = True self.boss = None self.monsters.clear() self.generation += 1 # Keep players but reset their state for player in self.players.values(): if not player.active: continue player.level = 1 player.exp = 0 player.health = player.max_health player.action_points = ACTION_POINTS_MAX player.inventory = {"wood": 0, "stone": 0} player.position = Position( random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), 0, ) def get_elapsed_time(self) -> float: if not self.is_game_active: return 0 return datetime.now().timestamp() - self.game_start_time def check_game_over(self) -> bool: if not self.is_game_active: return False elapsed = self.get_elapsed_time() if elapsed >= GAME_DURATION: self.is_game_active = False return True return False def update_tick(self): """Called periodically to update game state""" current_time = datetime.now().timestamp() # Spawn boss if conditions are met if self.boss is None and len(self.players) > 0: active_players = [p for p in self.players.values() if p.active] if active_players and any(p.level >= MIN_LEVEL_FOR_BOSS for p in active_players): self._spawn_boss() # Spawn monsters if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS: self._spawn_monster() # Regenerate player action points for player in self.players.values(): if not player.active: continue if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL: regen_amount = 1 nearby_bonuses = self._get_nearby_structure_bonuses(player.position) if "action_regen" in nearby_bonuses: regen_amount = nearby_bonuses["action_regen"] player.action_points = min( player.max_action_points, player.action_points + regen_amount, ) player.last_action_regen = current_time def _spawn_monster(self): """Spawn a random monster on the map""" monster_id = f"monster_{len(self.monsters)}_{self.generation}" level = random.randint(1, 5) x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) health = 20 + level * 10 self.monsters[monster_id] = Monster( id=monster_id, position=Position(x, y, 0), health=health, max_health=health, level=level, attack=3 + level, is_boss=False, ) def _spawn_boss(self): """Spawn the boss monster""" boss_id = f"boss_{self.generation}" active_players = [p for p in self.players.values() if p.active] avg_level = sum(p.level for p in active_players) / len(active_players) health = int(BOSS_HEALTH_BASE + avg_level * 500) # Spawn boss at a distance from players if active_players: player_pos = active_players[0].position angle = random.random() * 2 * 3.14159 x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1) y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1) else: x = y = 0 self.boss = Monster( id=boss_id, position=Position(x, y, 0), health=health, max_health=health, level=int(avg_level) + 5, attack=15 + int(avg_level), is_boss=True, ) def _get_nearby_structure_bonuses(self, position: Position, radius: int = 10) -> Dict: bonuses = {} for structure in self.structures.values(): dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5 if dist <= radius: for key, value in structure.bonuses.items(): bonuses[key] = bonuses.get(key, 0) + value return bonuses # Use plain dicts instead of Pydantic models # Global game state game_world = GameWorld() print("DEBUG: GameWorld initialized") app = FastAPI() print("DEBUG: FastAPI app created") # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ==================== API ENDPOINTS ==================== @app.post("/api/login") async def login(request: Request): """Login user and create/get player""" print("===== LOGIN START =====", flush=True) try: print("[1] Parsing JSON...", flush=True) body = await request.json() print(f"[2] Got body: {body}", flush=True) except Exception as e: print(f"[X] Failed to parse JSON: {e}", flush=True) raise HTTPException(status_code=400, detail="Invalid JSON") print("[3] Extracting fields...", flush=True) username = body.get("username", "").strip() color = body.get("color", "") print(f"[4] Got username={username}, color={color}", flush=True) if len(username) < 1 or len(username) > 30: print("[5a] Invalid username length", flush=True) raise HTTPException(status_code=400, detail="Invalid username length") print("[5b] Generating player ID...", flush=True) player_id = hashlib.md5(f"{username}_{game_world.generation}".encode()).hexdigest()[:12] print(f"[6] Player_id={player_id}", flush=True) print("[7] Checking if player exists...", flush=True) if player_id not in game_world.players: print("[8] Creating new player...", flush=True) player = Player( id=player_id, username=username, color=color, position=Position( random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), 0, ), ) print("[9] Adding player to world...", flush=True) game_world.players[player_id] = player print("[10] Player added", flush=True) print("[11] Getting player from world...", flush=True) player = game_world.players[player_id] player.active = True print("[12] Creating session...", flush=True) session_id = hashlib.md5(f"{player_id}_{datetime.now().timestamp()}".encode()).hexdigest()[:16] player.session_id = session_id game_world.connected_sessions[session_id] = player_id print("[13] Session created", flush=True) print("[14] Converting player to dict...", flush=True) player_dict = player.to_dict() print(f"[15] Player dict has {len(player_dict)} keys", flush=True) print("[16] Building response...", flush=True) response = { "player_id": player_id, "session_id": session_id, "player": player_dict, "game_active": game_world.is_game_active, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), } print("[17] Response built, returning...", flush=True) return response @app.get("/api/game/state") async def get_game_state(): """Get full game state""" return { "grid_size": GRID_SIZE, "players": {pid: p.to_dict() for pid, p in game_world.players.items()}, "monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()}, "boss": game_world.boss.to_dict() if game_world.boss else None, "structures": {sid: s.to_dict() for sid, s in game_world.structures.items()}, "resources": {rid: r.to_dict() for rid, r in game_world.resources.items()}, "game_active": game_world.is_game_active, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), } @app.post("/api/player/{player_id}/move") async def move_player(player_id: str, request: Request): """Move player""" if player_id not in game_world.players: raise HTTPException(status_code=404, detail="Player not found") body = await request.json() dx = body.get("dx", 0) dy = body.get("dy", 0) player = game_world.players[player_id] if not player.active: raise HTTPException(status_code=400, detail="Player not active") if player.action_points < 1: raise HTTPException(status_code=400, detail="Insufficient action points") # Calculate distance distance = (dx ** 2 + dy ** 2) ** 0.5 if distance > player.movement_capacity: raise HTTPException(status_code=400, detail="Movement exceeds capacity") player.position.x += dx player.position.y += dy player.position.x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x)) player.position.y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y)) player.action_points -= 1 return {"position": player.position.to_dict(), "action_points": player.action_points} @app.post("/api/player/{player_id}/action") async def player_action(player_id: str, request: Request): """Player performs an action""" if player_id not in game_world.players: raise HTTPException(status_code=404, detail="Player not found") body = await request.json() player = game_world.players[player_id] if player.action_points < 1: raise HTTPException(status_code=400, detail="Insufficient action points") action_type = body.get("action_type") if action_type == "attack": result = _handle_attack(player, body.get("target_id")) elif action_type == "gather": result = _handle_gather(player, body.get("target_id")) elif action_type == "build": result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type")) else: return {"action": action_type, "success": False, "reason": "Unknown action"} # Only consume AP if the action actually succeeded if result.get("success"): player.action_points -= 1 return result def _handle_attack(player: Player, target_id: str) -> Dict: """Handle player attack""" if target_id in game_world.monsters: monster = game_world.monsters[target_id] dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5 if dist > 5: return {"success": False, "reason": "Target too far"} damage = max(1, player.attack + random.randint(-2, 2) - monster.defense) monster.health -= damage if monster.health <= 0: del game_world.monsters[target_id] player.exp += monster.level * 10 player.level = 1 + int(player.exp / 100) return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True} return {"success": True, "damage": damage, "monster_health_remaining": monster.health} elif game_world.boss and target_id == game_world.boss.id: monster = game_world.boss dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5 if dist > 5: return {"success": False, "reason": "Boss too far"} damage = max(1, player.attack + random.randint(-2, 2) - monster.defense) monster.health -= damage old_progress = monster.boss_progress monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100 if monster.health <= 0: # Victory! for p in game_world.players.values(): if p.active: p.level += 5 p.exp += 500 game_world.boss = None return {"success": True, "damage": damage, "boss_killed": True} return {"success": True, "damage": damage, "boss_health_remaining": monster.health} return {"success": False, "reason": "Target not found"} def _handle_gather(player: Player, target_id: str) -> Dict: """Gather from all resources within GATHER_RADIUS of the player (target_id is ignored).""" GATHER_RADIUS = 5 gathered_total = {"wood": 0, "stone": 0} depleted = [] remaining_capacity = player.gathering_capacity for rid, resource in list(game_world.resources.items()): if remaining_capacity <= 0: break dist = ((resource.position.x - player.position.x) ** 2 + (resource.position.y - player.position.y) ** 2) ** 0.5 if dist <= GATHER_RADIUS: amount = min(remaining_capacity, resource.amount) resource.amount -= amount gathered_total[resource.resource_type] = gathered_total.get(resource.resource_type, 0) + amount remaining_capacity -= amount if resource.amount <= 0: depleted.append(rid) for rid in depleted: del game_world.resources[rid] total_gathered = sum(gathered_total.values()) if total_gathered == 0: return {"success": False, "reason": "No resources within reach (radius 5)"} for rtype, amt in gathered_total.items(): player.inventory[rtype] = player.inventory.get(rtype, 0) + amt return {"success": True, "gathered": gathered_total, "inventory": player.inventory} def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict: """Handle structure building""" if structure_type not in ["house", "farm", "guard_tower"]: return {"success": False, "reason": "Invalid structure type"} costs = {"house": {"wood": 20, "stone": 10}, "farm": {"wood": 15, "stone": 5}, "guard_tower": {"wood": 30, "stone": 20}} cost = costs[structure_type] for material, amount in cost.items(): if player.inventory.get(material, 0) < amount: return {"success": False, "reason": f"Insufficient {material}"} # Deduct cost for material, amount in cost.items(): player.inventory[material] -= amount # Create structure structure_id = f"struct_{len(game_world.structures)}" bonuses = {} if structure_type == "farm": bonuses["action_regen"] = 2 elif structure_type == "guard_tower": bonuses["defense"] = 2 elif structure_type == "house": bonuses["max_health"] = 20 game_world.structures[structure_id] = Structure( id=structure_id, position=Position(tx, ty, 0), structure_type=structure_type, owner_id=player.id, health=100, bonuses=bonuses, ) return {"success": True, "structure_id": structure_id, "inventory": player.inventory} @app.websocket("/ws/{player_id}") async def websocket_endpoint(websocket: WebSocket, player_id: str): """WebSocket for real-time game updates""" if player_id not in game_world.players: await websocket.close(code=4004, reason="Player not found") return player = game_world.players[player_id] await websocket.accept() try: while True: # Send game state updates every 100ms await asyncio.sleep(0.1) game_world.update_tick() if game_world.check_game_over(): await websocket.send_json({"type": "game_over", "time_remaining": 0}) break state = { "type": "state_update", "players": {pid: p.to_dict() for pid, p in game_world.players.items()}, "monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()}, "boss": game_world.boss.to_dict() if game_world.boss else None, "structures": {sid: s.to_dict() for sid, s in game_world.structures.items()}, "resources": {rid: r.to_dict() for rid, r in game_world.resources.items()}, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), "elapsed_time": game_world.get_elapsed_time(), } await websocket.send_json(state) except Exception as e: print(f"WebSocket error: {e}") finally: player.active = False @app.post("/api/game/start") async def start_game(): """Start a new game""" game_world.start_game() return {"game_active": True, "time_remaining": GAME_DURATION} @app.get("/api/health") async def health(): """Health check""" return {"status": "ok"} " />
|
||
<option name="updatedContent" value="import os import json import asyncio from datetime import datetime, timedelta from typing import Dict, List, Set from dataclasses import dataclass, field import random import hashlib from fastapi import FastAPI, WebSocket, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware # Configuration DEBUG_MODE = os.getenv("DEBUG", "0").strip().lower() in {"1", "true", "yes", "on"} GAME_DURATION = 30 * 60 # 30 minutes in seconds GRID_SIZE = 500 # Grid size in tiles PLAYER_START_SPAWN_RANGE = 50 BOSS_SPAWN_DISTANCE = 150 ACTION_POINTS_MAX = 20 # In debug mode AP regenerates much faster for rapid testing. ACTION_POINTS_REGEN_INTERVAL = float(os.getenv("ACTION_REGEN_INTERVAL", "1" if DEBUG_MODE else "60")) MONSTER_SPAWN_RATE = 0.1 # Probability per game tick MAX_MONSTERS = 50 BOSS_HEALTH_BASE = 1000 MIN_LEVEL_FOR_BOSS = 10 BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1 # Data Models @dataclass class Position: x: float y: float z: float = 0 def to_dict(self): return {"x": self.x, "y": self.y, "z": self.z} @classmethod def from_dict(cls, d): return cls(x=d["x"], y=d["y"], z=d.get("z", 0)) @dataclass class Player: id: str username: str color: str level: int = 1 exp: int = 0 position: Position = field(default_factory=lambda: Position(0, 0, 0)) health: int = 100 max_health: int = 100 action_points: int = ACTION_POINTS_MAX max_action_points: int = ACTION_POINTS_MAX last_action_regen: float = 0 inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0}) attack: int = 5 defense: int = 2 movement_capacity: int = 1 gathering_capacity: int = 10 active: bool = True session_id: str = "" def to_dict(self): return { "id": self.id, "username": self.username, "color": self.color, "level": self.level, "exp": self.exp, "position": self.position.to_dict(), "health": self.health, "max_health": self.max_health, "action_points": self.action_points, "max_action_points": self.max_action_points, "inventory": self.inventory, "attack": self.attack, "defense": self.defense, "movement_capacity": self.movement_capacity, "gathering_capacity": self.gathering_capacity, "active": self.active, } @dataclass class Monster: id: str position: Position health: int max_health: int level: int attack: int is_boss: bool = False boss_progress: float = 0.0 # Percentage of damage done def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "health": self.health, "max_health": self.max_health, "level": self.level, "attack": self.attack, "is_boss": self.is_boss, "boss_progress": self.boss_progress, } @dataclass class Structure: id: str position: Position structure_type: str # "house", "farm", "guard_tower" owner_id: str health: int bonuses: Dict = field(default_factory=dict) def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "structure_type": self.structure_type, "owner_id": self.owner_id, "health": self.health, "bonuses": self.bonuses, } @dataclass class Resource: id: str position: Position resource_type: str # "tree", "mountain" amount: int def to_dict(self): return { "id": self.id, "position": self.position.to_dict(), "resource_type": self.resource_type, "amount": self.amount, } class GameWorld: def __init__(self): self.players: Dict[str, Player] = {} self.monsters: Dict[str, Monster] = {} self.structures: Dict[str, Structure] = {} self.resources: Dict[str, Resource] = {} self.boss: Monster = None self.game_start_time: float = 0 self.is_game_active: bool = False self.connected_sessions: Dict[str, str] = {} # session_id -> player_id self.generation = 0 self._init_resources() def _init_resources(self): """Initialize static resources on the map""" for _ in range(50): resource_id = f"resource_{len(self.resources)}" x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) resource_type = random.choice(["tree", "mountain"]) self.resources[resource_id] = Resource( id=resource_id, position=Position(x, y, 0), resource_type=resource_type, amount=random.randint(50, 200), ) def start_game(self): """Start or restart the game""" self.game_start_time = datetime.now().timestamp() self.is_game_active = True self.boss = None self.monsters.clear() self.generation += 1 # Keep players but reset their state for player in self.players.values(): if not player.active: continue player.level = 1 player.exp = 0 player.health = player.max_health player.action_points = ACTION_POINTS_MAX player.inventory = {"wood": 0, "stone": 0} player.position = Position( random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), 0, ) def get_elapsed_time(self) -> float: if not self.is_game_active: return 0 return datetime.now().timestamp() - self.game_start_time def check_game_over(self) -> bool: if not self.is_game_active: return False elapsed = self.get_elapsed_time() if elapsed >= GAME_DURATION: self.is_game_active = False return True return False def update_tick(self): """Called periodically to update game state""" current_time = datetime.now().timestamp() # Spawn boss if conditions are met if self.boss is None and len(self.players) > 0: active_players = [p for p in self.players.values() if p.active] if active_players and any(p.level >= MIN_LEVEL_FOR_BOSS for p in active_players): self._spawn_boss() # Spawn monsters if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS: self._spawn_monster() # Regenerate player action points for player in self.players.values(): if not player.active: continue if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL: regen_amount = 1 nearby_bonuses = self._get_nearby_structure_bonuses(player.position) if "action_regen" in nearby_bonuses: regen_amount = nearby_bonuses["action_regen"] player.action_points = min( player.max_action_points, player.action_points + regen_amount, ) player.last_action_regen = current_time def _spawn_monster(self): """Spawn a random monster on the map""" monster_id = f"monster_{len(self.monsters)}_{self.generation}" level = random.randint(1, 5) x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) health = 20 + level * 10 self.monsters[monster_id] = Monster( id=monster_id, position=Position(x, y, 0), health=health, max_health=health, level=level, attack=3 + level, is_boss=False, ) def _spawn_boss(self): """Spawn the boss monster""" boss_id = f"boss_{self.generation}" active_players = [p for p in self.players.values() if p.active] avg_level = sum(p.level for p in active_players) / len(active_players) health = int(BOSS_HEALTH_BASE + avg_level * 500) # Spawn boss at a distance from players if active_players: player_pos = active_players[0].position angle = random.random() * 2 * 3.14159 x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1) y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1) else: x = y = 0 self.boss = Monster( id=boss_id, position=Position(x, y, 0), health=health, max_health=health, level=int(avg_level) + 5, attack=15 + int(avg_level), is_boss=True, ) def _get_nearby_structure_bonuses(self, position: Position, radius: int = 10) -> Dict: bonuses = {} for structure in self.structures.values(): dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5 if dist <= radius: for key, value in structure.bonuses.items(): bonuses[key] = bonuses.get(key, 0) + value return bonuses # Use plain dicts instead of Pydantic models # Global game state game_world = GameWorld() print("DEBUG: GameWorld initialized") app = FastAPI() print("DEBUG: FastAPI app created") # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ==================== API ENDPOINTS ==================== @app.post("/api/login") async def login(request: Request): """Login user and create/get player""" print("===== LOGIN START =====", flush=True) try: print("[1] Parsing JSON...", flush=True) body = await request.json() print(f"[2] Got body: {body}", flush=True) except Exception as e: print(f"[X] Failed to parse JSON: {e}", flush=True) raise HTTPException(status_code=400, detail="Invalid JSON") print("[3] Extracting fields...", flush=True) username = body.get("username", "").strip() color = body.get("color", "") print(f"[4] Got username={username}, color={color}", flush=True) if len(username) < 1 or len(username) > 30: print("[5a] Invalid username length", flush=True) raise HTTPException(status_code=400, detail="Invalid username length") print("[5b] Generating player ID...", flush=True) player_id = hashlib.md5(f"{username}_{game_world.generation}".encode()).hexdigest()[:12] print(f"[6] Player_id={player_id}", flush=True) print("[7] Checking if player exists...", flush=True) if player_id not in game_world.players: print("[8] Creating new player...", flush=True) player = Player( id=player_id, username=username, color=color, position=Position( random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), 0, ), ) print("[9] Adding player to world...", flush=True) game_world.players[player_id] = player print("[10] Player added", flush=True) print("[11] Getting player from world...", flush=True) player = game_world.players[player_id] player.active = True print("[12] Creating session...", flush=True) session_id = hashlib.md5(f"{player_id}_{datetime.now().timestamp()}".encode()).hexdigest()[:16] player.session_id = session_id game_world.connected_sessions[session_id] = player_id print("[13] Session created", flush=True) print("[14] Converting player to dict...", flush=True) player_dict = player.to_dict() print(f"[15] Player dict has {len(player_dict)} keys", flush=True) print("[16] Building response...", flush=True) response = { "player_id": player_id, "session_id": session_id, "player": player_dict, "game_active": game_world.is_game_active, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), } print("[17] Response built, returning...", flush=True) return response @app.get("/api/game/state") async def get_game_state(): """Get full game state""" return { "grid_size": GRID_SIZE, "players": {pid: p.to_dict() for pid, p in game_world.players.items()}, "monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()}, "boss": game_world.boss.to_dict() if game_world.boss else None, "structures": {sid: s.to_dict() for sid, s in game_world.structures.items()}, "resources": {rid: r.to_dict() for rid, r in game_world.resources.items()}, "game_active": game_world.is_game_active, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), } @app.post("/api/player/{player_id}/move") async def move_player(player_id: str, request: Request): """Move player""" if player_id not in game_world.players: raise HTTPException(status_code=404, detail="Player not found") body = await request.json() dx = body.get("dx", 0) dy = body.get("dy", 0) player = game_world.players[player_id] if not player.active: raise HTTPException(status_code=400, detail="Player not active") if player.action_points < 1: raise HTTPException(status_code=400, detail="Insufficient action points") # Calculate distance distance = (dx ** 2 + dy ** 2) ** 0.5 if distance > player.movement_capacity: raise HTTPException(status_code=400, detail="Movement exceeds capacity") player.position.x += dx player.position.y += dy player.position.x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x)) player.position.y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y)) player.action_points -= 1 return {"position": player.position.to_dict(), "action_points": player.action_points} @app.post("/api/player/{player_id}/action") async def player_action(player_id: str, request: Request): """Player performs an action""" if player_id not in game_world.players: raise HTTPException(status_code=404, detail="Player not found") body = await request.json() player = game_world.players[player_id] if player.action_points < 1: raise HTTPException(status_code=400, detail="Insufficient action points") action_type = body.get("action_type") if action_type == "attack": result = _handle_attack(player, body.get("target_id")) elif action_type == "gather": result = _handle_gather(player, body.get("target_id")) elif action_type == "build": result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type")) else: return {"action": action_type, "success": False, "reason": "Unknown action"} # Only consume AP if the action actually succeeded if result.get("success"): player.action_points -= 1 return result def _handle_attack(player: Player, target_id: str) -> Dict: """Handle player attack""" if target_id in game_world.monsters: monster = game_world.monsters[target_id] dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5 if dist > 5: return {"success": False, "reason": "Target too far"} damage = max(1, player.attack + random.randint(-2, 2) - monster.defense) monster.health -= damage if monster.health <= 0: del game_world.monsters[target_id] player.exp += monster.level * 10 player.level = 1 + int(player.exp / 100) return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True} return {"success": True, "damage": damage, "monster_health_remaining": monster.health} elif game_world.boss and target_id == game_world.boss.id: monster = game_world.boss dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5 if dist > 5: return {"success": False, "reason": "Boss too far"} damage = max(1, player.attack + random.randint(-2, 2) - monster.defense) monster.health -= damage old_progress = monster.boss_progress monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100 if monster.health <= 0: # Victory! for p in game_world.players.values(): if p.active: p.level += 5 p.exp += 500 game_world.boss = None return {"success": True, "damage": damage, "boss_killed": True} return {"success": True, "damage": damage, "boss_health_remaining": monster.health} return {"success": False, "reason": "Target not found"} def _handle_gather(player: Player, target_id: str) -> Dict: """Gather from all resources within GATHER_RADIUS of the player (target_id is ignored).""" GATHER_RADIUS = 5 gathered_total = {"wood": 0, "stone": 0} depleted = [] remaining_capacity = player.gathering_capacity for rid, resource in list(game_world.resources.items()): if remaining_capacity <= 0: break dist = ((resource.position.x - player.position.x) ** 2 + (resource.position.y - player.position.y) ** 2) ** 0.5 if dist <= GATHER_RADIUS: amount = min(remaining_capacity, resource.amount) resource.amount -= amount gathered_total[resource.resource_type] = gathered_total.get(resource.resource_type, 0) + amount remaining_capacity -= amount if resource.amount <= 0: depleted.append(rid) for rid in depleted: del game_world.resources[rid] total_gathered = sum(gathered_total.values()) if total_gathered == 0: return {"success": False, "reason": "No resources within reach (radius 5)"} for rtype, amt in gathered_total.items(): player.inventory[rtype] = player.inventory.get(rtype, 0) + amt return {"success": True, "gathered": gathered_total, "inventory": player.inventory} def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict: """Handle structure building""" if structure_type not in ["house", "farm", "guard_tower"]: return {"success": False, "reason": "Invalid structure type"} costs = {"house": {"wood": 20, "stone": 10}, "farm": {"wood": 15, "stone": 5}, "guard_tower": {"wood": 30, "stone": 20}} cost = costs[structure_type] for material, amount in cost.items(): if player.inventory.get(material, 0) < amount: return {"success": False, "reason": f"Insufficient {material}"} # Deduct cost for material, amount in cost.items(): player.inventory[material] -= amount # Create structure structure_id = f"struct_{len(game_world.structures)}" bonuses = {} if structure_type == "farm": bonuses["action_regen"] = 2 elif structure_type == "guard_tower": bonuses["defense"] = 2 elif structure_type == "house": bonuses["max_health"] = 20 game_world.structures[structure_id] = Structure( id=structure_id, position=Position(tx, ty, 0), structure_type=structure_type, owner_id=player.id, health=100, bonuses=bonuses, ) return {"success": True, "structure_id": structure_id, "inventory": player.inventory} @app.websocket("/ws/{player_id}") async def websocket_endpoint(websocket: WebSocket, player_id: str): """WebSocket for real-time game updates""" if player_id not in game_world.players: await websocket.close(code=4004, reason="Player not found") return player = game_world.players[player_id] await websocket.accept() try: while True: # Send game state updates every 100ms await asyncio.sleep(0.1) game_world.update_tick() if game_world.check_game_over(): await websocket.send_json({"type": "game_over", "time_remaining": 0}) break state = { "type": "state_update", "players": {pid: p.to_dict() for pid, p in game_world.players.items()}, "monsters": {mid: m.to_dict() for mid, m in game_world.monsters.items()}, "boss": game_world.boss.to_dict() if game_world.boss else None, "structures": {sid: s.to_dict() for sid, s in game_world.structures.items()}, "resources": {rid: r.to_dict() for rid, r in game_world.resources.items()}, "time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()), "elapsed_time": game_world.get_elapsed_time(), } await websocket.send_json(state) except Exception as e: print(f"WebSocket error: {e}") finally: player.active = False @app.post("/api/game/start") async def start_game(): """Start a new game""" game_world.start_game() return {"game_active": True, "time_remaining": GAME_DURATION} @app.get("/api/health") async def health(): """Health check""" return {"status": "ok"} " />
|
||
</PendingDiffInfo>
|
||
</value>
|
||
</entry>
|
||
<entry key="$PROJECT_DIR$/index.html">
|
||
<value>
|
||
<PendingDiffInfo>
|
||
<option name="filePath" value="$PROJECT_DIR$/index.html" />
|
||
<option name="originalContent" value="<!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: 10px; right: 10px; background: rgba(0, 0, 0, 0.9); border: 2px solid #667eea; border-radius: 5px; padding: 15px; } .button-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 10px; } .action-button { padding: 10px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; transition: all 0.3s; font-size: 0.9em; } .action-button:hover { background: #764ba2; transform: scale(1.05); } .action-button:disabled { background: #444; cursor: not-allowed; opacity: 0.5; } .status-message { margin-top: 10px; padding: 10px; background: #333; border-radius: 5px; min-height: 30px; } .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">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 class="cam-btn" id="camUp" title="Tilt up">▲</button> </div> <div class="cam-row"> <button class="cam-btn" id="camLeft" title="Orbit left">◀</button> <button class="cam-btn" id="camCenter" title="Re-center on player">⊙</button> <button class="cam-btn" id="camRight" title="Orbit right">▶</button> </div> <div class="cam-row"> <button class="cam-btn" id="camDown" title="Tilt down">▼</button> </div> <div class="cam-row" style="margin-top:4px; gap:4px;"> <button class="cam-btn wide" id="camZoomIn" title="Zoom in"> +</button> <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="button-group"> <button class="action-button" id="moveUpBtn">⬆ Move</button> <button class="action-button" id="moveDownBtn">⬇ Move</button> <button class="action-button" id="moveLeftBtn">⬅ Move</button> <button class="action-button" id="moveRightBtn">➡ Move</button> </div> <div class="button-group" style="margin-top: 10px;"> <button class="action-button" id="gatherBtn"> Gather</button> <button class="action-button" id="attackBtn">⚔️ Attack</button> </div> <div class="button-group" style="margin-top: 10px;"> <button class="action-button" id="buildHouseBtn"> House</button> <button class="action-button" id="buildFarmBtn"> Farm</button> <button class="action-button" id="buildTowerBtn">️ Tower</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; 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.attack; 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 = {}; function initScene() { const canvas = document.getElementById('gameCanvas'); scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a0a); 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. // 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 const gridHelper = new THREE.GridHelper(500, 50, 0x444444, 0x222222); 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 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); playerSpheres[player.id] = { mesh: sphere, sprite }; } 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); } } 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; } 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 createResourceMesh(resource) { let mesh; if (resource.resource_type === 'tree') { const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 0.8, 8); const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); const trunk = new THREE.Mesh(trunkGeo, trunkMat); trunk.position.y = 0.4; const foliageMat = new THREE.MeshPhongMaterial({ color: 0x228b22 }); const foliage1 = new THREE.Mesh(new THREE.ConeGeometry(0.85, 1.3, 8), foliageMat); foliage1.position.y = 1.3; const foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.0, 8), foliageMat); foliage2.position.y = 2.1; mesh = new THREE.Group(); mesh.add(trunk, foliage1, foliage2); } else { const rockMat = new THREE.MeshPhongMaterial({ color: 0x8a8a8a, flatShading: true }); const main = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), rockMat); main.position.y = 0.5; main.rotation.y = Math.random() * Math.PI; const side1 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.38, 0), rockMat); side1.position.set(0.6, 0.25, 0.12); side1.rotation.y = Math.random() * Math.PI; const side2 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28, 0), rockMat); side2.position.set(-0.5, 0.2, 0.25); side2.rotation.y = Math.random() * Math.PI; mesh = new THREE.Group(); mesh.add(main, side1, side2); } mesh.position.set(resource.position.x, 0, resource.position.y); mesh.castShadow = 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); 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]; } } 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) { 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 }), }); 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 = () => 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 = () => focusCameraOnPlayer(true); document.getElementById('attackBtn').onclick = () => { if (gameState && gameState.boss) { performAction('attack', gameState.boss.id); } else if (gameState && Object.keys(gameState.monsters).length > 0) { const targetId = Object.keys(gameState.monsters)[0]; performAction('attack', targetId); } else { showMessage('No targets available'); } }; 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'); }; } // ==================== 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> " />
|
||
<option name="updatedContent" value="<!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">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 class="cam-btn" id="camUp" title="Tilt up">▲</button> </div> <div class="cam-row"> <button class="cam-btn" id="camLeft" title="Orbit left">◀</button> <button class="cam-btn" id="camCenter" title="Re-center on player">⊙</button> <button class="cam-btn" id="camRight" title="Orbit right">▶</button> </div> <div class="cam-row"> <button class="cam-btn" id="camDown" title="Tilt down">▼</button> </div> <div class="cam-row" style="margin-top:4px; gap:4px;"> <button class="cam-btn wide" id="camZoomIn" title="Zoom in"> +</button> <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 class="action-button" id="moveUpBtn" title="Move north by 1 tile (cost: 1 AP)">⬆ N</button> <button class="action-button" id="moveDownBtn" title="Move south by 1 tile (cost: 1 AP)">⬇ S</button> <button class="action-button" id="moveLeftBtn" title="Move west by 1 tile (cost: 1 AP)">⬅ W</button> <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 class="action-button" id="gatherBtn" title="Gather nearby trees/mountains within radius 5 (cost: 1 AP)"> Gather</button> <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 class="action-button" id="buildHouseBtn" title="Build House (20 wood, 10 stone)"> House</button> <button class="action-button" id="buildFarmBtn" title="Build Farm (15 wood, 5 stone)"> Farm</button> <button class="action-button" id="buildTowerBtn" title="Build Guard Tower (30 wood, 20 stone)">️ Tower</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; 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.attack; 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 = {}; function initScene() { const canvas = document.getElementById('gameCanvas'); scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a0a); 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. // 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 const gridHelper = new THREE.GridHelper(500, 50, 0x444444, 0x222222); 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); playerSpheres[player.id] = { mesh: sphere, sprite }; } 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); } } 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; } 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 createResourceMesh(resource) { let mesh; if (resource.resource_type === 'tree') { const trunkGeo = new THREE.CylinderGeometry(0.12, 0.18, 0.8, 8); const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); const trunk = new THREE.Mesh(trunkGeo, trunkMat); trunk.position.y = 0.4; const foliageMat = new THREE.MeshPhongMaterial({ color: 0x228b22 }); const foliage1 = new THREE.Mesh(new THREE.ConeGeometry(0.85, 1.3, 8), foliageMat); foliage1.position.y = 1.3; const foliage2 = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.0, 8), foliageMat); foliage2.position.y = 2.1; mesh = new THREE.Group(); mesh.add(trunk, foliage1, foliage2); } else { const rockMat = new THREE.MeshPhongMaterial({ color: 0x8a8a8a, flatShading: true }); const main = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), rockMat); main.position.y = 0.5; main.rotation.y = Math.random() * Math.PI; const side1 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.38, 0), rockMat); side1.position.set(0.6, 0.25, 0.12); side1.rotation.y = Math.random() * Math.PI; const side2 = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28, 0), rockMat); side2.position.set(-0.5, 0.2, 0.25); side2.rotation.y = Math.random() * Math.PI; mesh = new THREE.Group(); mesh.add(main, side1, side2); } mesh.position.set(resource.position.x, 0, resource.position.y); mesh.castShadow = 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); 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]; } } 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) { 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 }), }); 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 = () => 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 = () => 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(); } 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 && gameState.boss) { performAction('attack', gameState.boss.id); } else if (gameState && Object.keys(gameState.monsters).length > 0) { const targetId = Object.keys(gameState.monsters)[0]; performAction('attack', targetId); } else { showMessage('No targets available'); } }; 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'); }; } // ==================== 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> " />
|
||
</PendingDiffInfo>
|
||
</value>
|
||
</entry>
|
||
</map>
|
||
</option>
|
||
</component>
|
||
</project> |