diff --git a/backend.py b/backend.py index 79920f6..c6a7c43 100644 --- a/backend.py +++ b/backend.py @@ -13,18 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware 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 +GRID_SIZE = 200 # Grid size in tiles PLAYER_START_SPAWN_RANGE = 50 -BOSS_SPAWN_DISTANCE = 150 -ACTION_POINTS_MAX = 20 +BOSS_SPAWN_DISTANCE = 70 +ACTION_POINTS_MAX = 30 # 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 +HP_REGEN_INTERVAL = float(os.getenv("HP_REGEN_INTERVAL", "2" if DEBUG_MODE else "15")) # seconds per 1 HP +HP_REGEN_AMOUNT = 1 # HP healed per interval +MONSTER_SPAWN_RATE = 0.05 # Probability per game tick MAX_MONSTERS = 50 BOSS_HEALTH_BASE = 1000 MIN_LEVEL_FOR_BOSS = 10 BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1 -RESOURCE_NODE_COUNT = 180 +RESOURCE_NODE_COUNT = 540 CHEST_SPAWN_RATE = 0.015 MAX_CHESTS = 15 CHEST_LOOT_RANGE = 3 @@ -60,6 +62,7 @@ class Player: action_points: int = ACTION_POINTS_MAX max_action_points: int = ACTION_POINTS_MAX last_action_regen: float = 0 + last_hp_regen: float = 0 inventory: Dict[str, int] = field(default_factory=lambda: {"wood": 0, "stone": 0}) attack: int = 5 defense: int = 2 @@ -190,6 +193,9 @@ class GameWorld: self.generation = 0 self._init_resources() self._init_chests() + # Auto-start a game session immediately so the timer runs from launch. + self.game_start_time = datetime.now().timestamp() + self.is_game_active = True def _init_resources(self): """Initialize static resources on the map""" @@ -218,6 +224,27 @@ class GameWorld: {"name": "Long Spear", "attack_bonus": 2, "range_bonus": 2}, {"name": "Hunting Bow", "attack_bonus": 1, "range_bonus": 3}, {"name": "Battle Axe", "attack_bonus": 3, "range_bonus": 1}, + { + "name": "Banner of Vigor", + "attack_bonus": 1, + "range_bonus": 1, + "aura_radius": 6, + "aura_bonuses": {"action_regen": 1, "hp_regen": 1}, + }, + { + "name": "Warden Standard", + "attack_bonus": 0, + "range_bonus": 1, + "aura_radius": 6, + "aura_bonuses": {"defense": 2}, + }, + { + "name": "Windstep Boots", + "attack_bonus": 0, + "range_bonus": 0, + "aura_radius": 5, + "aura_bonuses": {"free_move_chance": 0.5}, + }, ] self.chests[chest_id] = Chest( id=chest_id, @@ -293,15 +320,23 @@ class GameWorld: continue if current_time - player.last_action_regen >= ACTION_POINTS_REGEN_INTERVAL: regen_amount = 1 - nearby_bonuses = self._get_nearby_structure_bonuses(player.position) + nearby_bonuses = _player_nearby_bonuses(player) if "action_regen" in nearby_bonuses: - regen_amount = nearby_bonuses["action_regen"] + regen_amount += int(nearby_bonuses["action_regen"]) player.action_points = min( player.max_action_points, player.action_points + regen_amount, ) player.last_action_regen = current_time + # HP regeneration + if current_time - player.last_hp_regen >= HP_REGEN_INTERVAL: + effective_max_health = _player_effective_max_health(player) + hp_regen_amount = HP_REGEN_AMOUNT + int(_player_nearby_bonuses(player).get("hp_regen", 0)) + if player.health < effective_max_health: + player.health = min(effective_max_health, player.health + hp_regen_amount) + player.last_hp_regen = current_time + def _spawn_monster(self): """Spawn a random monster on the map""" monster_id = f"monster_{len(self.monsters)}_{self.generation}" @@ -361,6 +396,58 @@ def _player_effective_range(player: Player) -> float: return player.attack_range + float(w.get("range_bonus", 0)) return player.attack_range +def _player_nearby_bonuses(player: Player) -> Dict: + if "game_world" not in globals(): + return {} + bonuses = dict(game_world._get_nearby_structure_bonuses(player.position)) + + for other_player in game_world.players.values(): + if not other_player.active: + continue + if not (0 <= other_player.equipped_weapon_slot < len(other_player.weapon_slots)): + continue + item = other_player.weapon_slots[other_player.equipped_weapon_slot] + if not item: + continue + aura_bonuses = item.get("aura_bonuses") or {} + aura_radius = float(item.get("aura_radius", 0)) + if not aura_bonuses or aura_radius <= 0: + continue + + dist = ((other_player.position.x - player.position.x) ** 2 + (other_player.position.y - player.position.y) ** 2) ** 0.5 + if dist > aura_radius: + continue + + for key, value in aura_bonuses.items(): + bonuses[key] = bonuses.get(key, 0) + value + + return bonuses + +def _player_effective_defense(player: Player) -> int: + bonuses = _player_nearby_bonuses(player) + return player.defense + int(bonuses.get("defense", 0)) + +def _player_effective_max_health(player: Player) -> int: + bonuses = _player_nearby_bonuses(player) + return player.max_health + int(bonuses.get("max_health", 0)) + +def _serialize_player(player: Player) -> Dict: + data = player.to_dict() + bonuses = _player_nearby_bonuses(player) + effective_defense = _player_effective_defense(player) + effective_max_health = _player_effective_max_health(player) + + if player.health > effective_max_health: + player.health = effective_max_health + + data["base_defense"] = player.defense + data["defense"] = effective_defense + data["base_max_health"] = player.max_health + data["max_health"] = effective_max_health + data["health"] = player.health + data["nearby_bonuses"] = bonuses + return data + def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict: bonuses = {} for structure in self.structures.values(): @@ -444,7 +531,7 @@ async def login(request: Request): print("[13] Session created", flush=True) print("[14] Converting player to dict...", flush=True) - player_dict = player.to_dict() + player_dict = _serialize_player(player) print(f"[15] Player dict has {len(player_dict)} keys", flush=True) print("[16] Building response...", flush=True) @@ -463,7 +550,7 @@ 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()}, + "players": {pid: _serialize_player(p) 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()}, @@ -487,7 +574,11 @@ async def move_player(player_id: str, request: Request): if not player.active: raise HTTPException(status_code=400, detail="Player not active") - if player.action_points < 1: + nearby_bonuses = _player_nearby_bonuses(player) + free_move_chance = max(0.0, min(0.95, float(nearby_bonuses.get("free_move_chance", 0)))) + ap_cost = 0 if random.random() < free_move_chance else 1 + + if player.action_points < ap_cost: raise HTTPException(status_code=400, detail="Insufficient action points") # Calculate distance @@ -508,9 +599,14 @@ async def move_player(player_id: str, request: Request): player.position.x = next_x player.position.y = next_y - player.action_points -= 1 + player.action_points -= ap_cost - return {"position": player.position.to_dict(), "action_points": player.action_points} + return { + "position": player.position.to_dict(), + "action_points": player.action_points, + "ap_cost": ap_cost, + "free_move_triggered": ap_cost == 0, + } @app.post("/api/player/{player_id}/action") async def player_action(player_id: str, request: Request): @@ -520,10 +616,16 @@ async def player_action(player_id: str, request: Request): body = await request.json() player = game_world.players[player_id] + action_type = body.get("action_type") + + # Equip is free โ€” no AP required + if action_type == "equip": + result = _handle_equip(player, body.get("slot_index")) + return result + 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": @@ -547,6 +649,36 @@ def _handle_attack(player: Player, target_id: str) -> Dict: """Handle player attack""" attack_range = _player_effective_range(player) attack_power = _player_effective_attack(player) + effective_defense = _player_effective_defense(player) + + def max_retaliation_damage(monster_attack: int) -> int: + # Retaliation roll is random in [-1, +2], so +2 is the worst-case outcome. + return max(0, monster_attack + 2 - effective_defense) + + def apply_retaliation(monster_attack: int) -> Dict: + retaliation_damage = max(0, monster_attack + random.randint(-1, 2) - effective_defense) + if retaliation_damage <= 0: + return {"retaliation_damage": 0, "player_health": player.health, "player_defeated": False} + + player.health = max(0, player.health - retaliation_damage) + defeated = player.health <= 0 + respawn_position = None + + if defeated: + player.health = player.max_health + player.position = Position( + random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), + random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE), + 0, + ) + respawn_position = player.position.to_dict() + + return { + "retaliation_damage": retaliation_damage, + "player_health": player.health, + "player_defeated": defeated, + "respawn_position": respawn_position, + } if target_id in game_world.monsters: monster = game_world.monsters[target_id] @@ -555,6 +687,12 @@ def _handle_attack(player: Player, target_id: str) -> Dict: if dist > attack_range: return {"success": False, "reason": "Target too far"} + min_damage = max(1, attack_power - 2 - monster.defense) + # If monster certainly survives this hit and guaranteed max retaliation is lethal, + # block the attack before consuming AP. + if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack): + return {"success": False, "reason": "Attack blocked: retaliation would be lethal"} + damage = max(1, attack_power + random.randint(-2, 2) - monster.defense) monster.health -= damage @@ -564,7 +702,13 @@ def _handle_attack(player: Player, target_id: str) -> Dict: 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} + retaliation = apply_retaliation(monster.attack) + return { + "success": True, + "damage": damage, + "monster_health_remaining": monster.health, + **retaliation, + } elif game_world.boss and target_id == game_world.boss.id: monster = game_world.boss @@ -573,6 +717,10 @@ def _handle_attack(player: Player, target_id: str) -> Dict: if dist > attack_range: return {"success": False, "reason": "Boss too far"} + min_damage = max(1, attack_power - 2 - monster.defense) + if monster.health > min_damage and player.health <= max_retaliation_damage(monster.attack + 2): + return {"success": False, "reason": "Attack blocked: boss retaliation would be lethal"} + damage = max(1, attack_power + random.randint(-2, 2) - monster.defense) monster.health -= damage old_progress = monster.boss_progress @@ -587,7 +735,13 @@ def _handle_attack(player: Player, target_id: str) -> Dict: game_world.boss = None return {"success": True, "damage": damage, "boss_killed": True} - return {"success": True, "damage": damage, "boss_health_remaining": monster.health} + retaliation = apply_retaliation(monster.attack + 2) + return { + "success": True, + "damage": damage, + "boss_health_remaining": monster.health, + **retaliation, + } if target_id in game_world.structures: structure = game_world.structures[target_id] @@ -607,20 +761,22 @@ def _handle_attack(player: Player, target_id: str) -> Dict: 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 + # Map resource_type -> inventory key + RESOURCE_MAP = {"tree": "wood", "mountain": "stone"} 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) + inv_key = RESOURCE_MAP.get(resource.resource_type) + if not inv_key: + continue + # Gather from every node in range (per-node cap = gathering_capacity). + amount = min(player.gathering_capacity, resource.amount) resource.amount -= amount - gathered_total[resource.resource_type] = gathered_total.get(resource.resource_type, 0) + amount - remaining_capacity -= amount + gathered_total[inv_key] = gathered_total.get(inv_key, 0) + amount if resource.amount <= 0: depleted.append(rid) @@ -631,8 +787,8 @@ def _handle_gather(player: Player, target_id: str) -> Dict: 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 + for inv_key, amt in gathered_total.items(): + player.inventory[inv_key] = player.inventory.get(inv_key, 0) + amt return {"success": True, "gathered": gathered_total, "inventory": player.inventory} @@ -649,27 +805,29 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> } 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 (block is placed 1 tile north of player) structure_id = f"struct_{len(game_world.structures)}" bonuses = {} px, py = tx, ty if structure_type == "block": px, py = player.position.x, player.position.y - 1 - # Cannot place on top of players or existing structures. + # Cannot place on top of players. for p in game_world.players.values(): if ((p.position.x - px) ** 2 + (p.position.y - py) ** 2) ** 0.5 < 0.6: return {"success": False, "reason": "Cannot place block on player"} - for s in game_world.structures.values(): - if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6: - return {"success": False, "reason": "Tile already occupied"} + + # No structure can be placed on an existing structure. + for s in game_world.structures.values(): + if ((s.position.x - px) ** 2 + (s.position.y - py) ** 2) ** 0.5 < 0.6: + return {"success": False, "reason": "Tile already occupied by a structure"} + + for material, amount in cost.items(): + if player.inventory.get(material, 0) < amount: + return {"success": False, "reason": f"Insufficient {material}"} + + # Deduct cost only after placement is validated. + for material, amount in cost.items(): + player.inventory[material] -= amount if structure_type == "farm": bonuses["action_regen"] = 2 @@ -747,7 +905,7 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str): state = { "type": "state_update", - "players": {pid: p.to_dict() for pid, p in game_world.players.items()}, + "players": {pid: _serialize_player(p) 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()}, diff --git a/index.html b/index.html index aae6298..93bc000 100644 --- a/index.html +++ b/index.html @@ -181,6 +181,18 @@ background: #44ff44; } + .stat-bar-fill.exp { + background: #f1c40f; + } + + .inv-value { transition: color 0.15s; } + .inv-value.flash { color: #66ff88 !important; } + + @keyframes inv-flash { + 0% { color: #66ff88; } + 100% { color: inherit; } + } + .inventory { margin-top: 20px; border-top: 1px solid #667eea; @@ -385,6 +397,9 @@ Experience: 0 +
+
+
Health: 100/100 @@ -416,16 +431,22 @@ 5
+
+
Nearby Buffs
+
No nearby buffs
+
Inventory
- Wood: - 0 + ๐Ÿชต Wood: + 0
- Stone: - 0 + ๐Ÿชจ Stone: + 0
+
Equipment
+
@@ -489,14 +510,13 @@
- - - + + +
- - - + +
Ready for adventure!
@@ -522,6 +542,7 @@ let ws = null; let cameraKeyBindingsAttached = false; let cameraAutoFollow = true; + let cameraFollowOnMove = true; const API_BASE = 'http://localhost:8000/api'; @@ -530,22 +551,94 @@ document.getElementById('statusMessage').textContent = msg; } + function flashEl(id) { + const el = document.getElementById(id); + if (!el) return; + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + setTimeout(() => el.classList.remove('flash'), 600); + } + function updateUI() { if (!currentPlayer) return; document.getElementById('statLevel').textContent = currentPlayer.level; document.getElementById('statExp').textContent = currentPlayer.exp; + const levelProgress = ((currentPlayer.exp || 0) % 100); + document.getElementById('expBar').style.width = `${Math.max(0, Math.min(100, levelProgress))}%`; document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`; document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`; document.getElementById('statActionPoints').textContent = currentPlayer.action_points; document.getElementById('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`; document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack; document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1); - document.getElementById('statDefense').textContent = currentPlayer.defense; + const nearbyBonuses = currentPlayer.nearby_bonuses || {}; + const defenseBonus = Number(nearbyBonuses.defense || 0); + const baseDefense = Number(currentPlayer.base_defense ?? currentPlayer.defense ?? 0); + const effectiveDefense = Number(currentPlayer.defense ?? baseDefense); + document.getElementById('statDefense').textContent = defenseBonus > 0 + ? `${baseDefense} (+${defenseBonus}) = ${effectiveDefense}` + : effectiveDefense; document.getElementById('statMove').textContent = currentPlayer.movement_capacity; - document.getElementById('invWood').textContent = currentPlayer.inventory.wood || 0; - document.getElementById('invStone').textContent = currentPlayer.inventory.stone || 0; + const buffRows = []; + const actionRegen = Number(nearbyBonuses.action_regen || 0); + const healthBonus = Number(nearbyBonuses.max_health || 0); + const hpRegenBonus = Number(nearbyBonuses.hp_regen || 0); + const freeMoveChance = Number(nearbyBonuses.free_move_chance || 0); + if (actionRegen > 0) buffRows.push(`โšก Nearby aura: +${actionRegen} AP per regen tick`); + if (defenseBonus > 0) buffRows.push(`๐Ÿ›ก๏ธ Nearby aura: +${defenseBonus} defense`); + if (healthBonus > 0) buffRows.push(`โค๏ธ Nearby aura: +${healthBonus} max HP`); + if (hpRegenBonus > 0) buffRows.push(`๐Ÿ’š Nearby aura: +${hpRegenBonus} HP per regen tick`); + if (freeMoveChance > 0) buffRows.push(`๐Ÿ‘ข Nearby aura: ${Math.round(freeMoveChance * 100)}% chance moves cost 0 AP`); + document.getElementById('activeBuffs').innerHTML = buffRows.length > 0 + ? buffRows.join('
') + : 'No nearby buffs'; + + const newWood = currentPlayer.inventory.wood || 0; + const newStone = currentPlayer.inventory.stone || 0; + const prevWood = parseInt(document.getElementById('invWood').textContent) || 0; + const prevStone = parseInt(document.getElementById('invStone').textContent) || 0; + document.getElementById('invWood').textContent = newWood; + document.getElementById('invStone').textContent = newStone; + if (newWood !== prevWood) flashEl('invWood'); + if (newStone !== prevStone) flashEl('invStone'); + + // Weapon slots + const slots = currentPlayer.weapon_slots || []; + const equippedIdx = currentPlayer.equipped_weapon_slot ?? -1; + const slotEl = document.getElementById('weaponSlots'); + if (slotEl) { + const filled = slots.filter(Boolean); + if (filled.length === 0) { + slotEl.innerHTML = 'No weapons'; + } else { + slotEl.innerHTML = slots.map((w, i) => { + if (!w) return ''; + const eq = i === equippedIdx; + const border = eq ? 'border:1px solid #f1c40f' : 'border:1px solid #444'; + const bg = eq ? 'background:#2a2600' : 'background:#1e1e1e'; + const icon = eq ? 'โš”๏ธ ' : 'ยท '; + const aura = w.aura_bonuses || {}; + const auraParts = []; + if (aura.action_regen) auraParts.push(`+${aura.action_regen} AP regen`); + if (aura.hp_regen) auraParts.push(`+${aura.hp_regen} HP regen`); + if (aura.defense) auraParts.push(`+${aura.defense} def`); + if (aura.max_health) auraParts.push(`+${aura.max_health} max HP`); + if (aura.free_move_chance) auraParts.push(`${Math.round(aura.free_move_chance * 100)}% free moves`); + const auraLine = auraParts.length > 0 + ? `
Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}
` + : ''; + return `
+ ${icon}${w.name} + +${w.attack_bonus}atk +${w.range_bonus}rng + ${auraLine} +
`; + }).join(''); + } + } if (gameState) { const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length; @@ -568,6 +661,8 @@ const structureObjects = {}; const resourceObjects = {}; const chestObjects = {}; + const worldEffects = []; + let previousFrameTime = performance.now(); function initScene() { const canvas = document.getElementById('gameCanvas'); @@ -619,7 +714,7 @@ scene.add(directionalLight); // Ground plane - const groundGeometry = new THREE.PlaneGeometry(500, 500); + const groundGeometry = new THREE.PlaneGeometry(200, 200); const groundMaterial = new THREE.MeshLambertMaterial({ color: 0x1a3a1a }); const ground = new THREE.Mesh(groundGeometry, groundMaterial); ground.rotation.x = -Math.PI / 2; @@ -627,7 +722,7 @@ scene.add(ground); // Grid helper: smaller cells so 1-tile movement matches visible grid steps. - const gridHelper = new THREE.GridHelper(500, 500, 0x4a5d66, 0x2b3b42); + const gridHelper = new THREE.GridHelper(200, 200, 0x4a5d66, 0x2b3b42); gridHelper.position.y = 0.1; scene.add(gridHelper); @@ -730,21 +825,28 @@ 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); + // Compact nameplate โ€” canvas is exactly as tall as the pill so no + // transparent headroom inflates the visible sprite quad. + const nameCvs = document.createElement('canvas'); + const nameCtx = nameCvs.getContext('2d'); + nameCvs.width = 256; + nameCvs.height = 44; // just enough for one line of 28px text + nameCtx.font = 'bold 28px Arial'; + const textWidth = nameCtx.measureText(player.username).width; + const bgWidth = Math.min(nameCvs.width, textWidth + 22); + const bgX = (nameCvs.width - bgWidth) / 2; + drawRoundedRect(nameCtx, bgX, 0, bgWidth, nameCvs.height, 8, 'rgba(0,0,0,0.50)'); + nameCtx.fillStyle = '#ffffff'; + nameCtx.textAlign = 'center'; + nameCtx.textBaseline = 'middle'; + nameCtx.fillText(player.username, nameCvs.width / 2, nameCvs.height / 2); - const texture = new THREE.CanvasTexture(canvas); - const spriteMaterial = new THREE.SpriteMaterial({ map: texture }); + // Scale sprite so world-height matches canvas aspect: width=3.2, height=3.2*(44/256)โ‰ˆ0.55 + const texture = new THREE.CanvasTexture(nameCvs); + const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }); const sprite = new THREE.Sprite(spriteMaterial); - sprite.scale.set(4, 2, 1); + const spriteW = 3.2; + sprite.scale.set(spriteW, spriteW * (nameCvs.height / nameCvs.width), 1); sprite.position.set(player.position.x, 2.5, player.position.y); scene.add(sprite); @@ -753,10 +855,29 @@ const northArrow = new THREE.Mesh(northGeo, northMat); // Rotate so the tip points north (-Z direction in Three.js world space). northArrow.rotation.x = -Math.PI / 2; - northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8); + northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1); scene.add(northArrow); playerSpheres[player.id] = { mesh: sphere, sprite, northArrow }; + setPlayerVisualState(player); + } + + function setPlayerVisualState(player) { + const sphereObj = playerSpheres[player.id]; + if (!sphereObj) return; + const isActive = player.active !== false; + const opacity = isActive ? 1.0 : 0.35; + + sphereObj.mesh.material.transparent = !isActive; + sphereObj.mesh.material.opacity = opacity; + + sphereObj.sprite.material.transparent = !isActive; + sphereObj.sprite.material.opacity = opacity; + + if (sphereObj.northArrow) { + sphereObj.northArrow.material.transparent = !isActive; + sphereObj.northArrow.material.opacity = opacity; + } } function updatePlayerSphere(player) { @@ -764,11 +885,87 @@ playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y); playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y); if (playerSpheres[player.id].northArrow) { - playerSpheres[player.id].northArrow.position.set(player.position.x, 0.9, player.position.y - 0.8); + playerSpheres[player.id].northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1); } + setPlayerVisualState(player); } } + function createTextSprite(initialText, textColor = '#ffffff', bgColor = 'rgba(0, 0, 0, 0.7)') { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 96; + const ctx = canvas.getContext('2d'); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(3.5, 1.3, 1); + + const spriteObj = { sprite, canvas, ctx, texture, textColor, bgColor }; + updateTextSprite(spriteObj, initialText); + return spriteObj; + } + + function drawRoundedRect(ctx, x, y, width, height, radius, fillStyle) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + ctx.fillStyle = fillStyle; + ctx.fill(); + } + + function createProgressBarSprite() { + const canvas = document.createElement('canvas'); + canvas.width = 200; + canvas.height = 40; + const ctx = canvas.getContext('2d'); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(2.2, 0.35, 1); + + return { sprite, canvas, ctx, texture }; + } + + function updateProgressBarSprite(barObj, ratio, fillColor = '#ff5555') { + const { canvas, ctx, texture } = barObj; + const clampedRatio = Math.max(0, Math.min(1, ratio)); + const pad = 4; + const trackX = 12; + const trackY = 10; + const trackW = canvas.width - 24; + const trackH = 20; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawRoundedRect(ctx, trackX - pad, trackY - pad, trackW + pad * 2, trackH + pad * 2, 8, 'rgba(0, 0, 0, 0.65)'); + drawRoundedRect(ctx, trackX, trackY, trackW, trackH, 6, '#333333'); + drawRoundedRect(ctx, trackX, trackY, trackW * clampedRatio, trackH, 6, fillColor); + texture.needsUpdate = true; + } + + function updateTextSprite(spriteObj, text) { + const { canvas, ctx, texture, textColor, bgColor } = spriteObj; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = textColor; + ctx.font = 'bold 30px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + texture.needsUpdate = true; + } + function createMonsterCube(monster) { const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6); const material = new THREE.MeshPhongMaterial({ color: 0xff4444 }); @@ -776,7 +973,70 @@ cube.position.set(monster.position.x, 0.3, monster.position.y); cube.castShadow = true; scene.add(cube); - monsterCubes[monster.id] = cube; + + const label = createTextSprite(`Lv ${monster.level} HP ${monster.health}/${monster.max_health}`, '#ffdede'); + label.sprite.position.set(monster.position.x, 1.25, monster.position.y); + scene.add(label.sprite); + + const hpBar = createProgressBarSprite(); + hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y); + updateProgressBarSprite(hpBar, monster.max_health > 0 ? monster.health / monster.max_health : 0); + scene.add(hpBar.sprite); + + monsterCubes[monster.id] = { mesh: cube, label, hpBar }; + } + + function spawnFloatingText(text, worldX, worldZ, color = '#ffffff') { + const textObj = createTextSprite(text, color, 'rgba(0, 0, 0, 0.55)'); + textObj.sprite.scale.set(2.5, 0.95, 1); + textObj.sprite.position.set(worldX, 1.4, worldZ); + scene.add(textObj.sprite); + worldEffects.push({ + type: 'text', + object: textObj.sprite, + life: 0.9, + maxLife: 0.9, + velocityY: 1.6, + }); + } + + function spawnGroundPulse(worldX, worldZ, color = 0xffffff) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(0.22, 0.34, 24), + new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9, side: THREE.DoubleSide }) + ); + ring.rotation.x = -Math.PI / 2; + ring.position.set(worldX, 0.06, worldZ); + scene.add(ring); + worldEffects.push({ + type: 'pulse', + object: ring, + life: 0.45, + maxLife: 0.45, + growth: 2.2, + }); + } + + function updateWorldEffects(deltaTime) { + for (let i = worldEffects.length - 1; i >= 0; i--) { + const fx = worldEffects[i]; + fx.life -= deltaTime; + if (fx.life <= 0) { + scene.remove(fx.object); + worldEffects.splice(i, 1); + continue; + } + + const t = fx.life / fx.maxLife; + if (fx.type === 'text') { + fx.object.position.y += fx.velocityY * deltaTime; + fx.object.material.opacity = t; + } else if (fx.type === 'pulse') { + const scale = 1 + (1 - t) * fx.growth; + fx.object.scale.set(scale, scale, scale); + fx.object.material.opacity = t; + } + } } function createBossMesh(monster) { @@ -833,15 +1093,39 @@ function createResourceMesh(resource) { const isTree = resource.resource_type === 'tree'; - const geometry = isTree - ? new THREE.ConeGeometry(0.45, 1.1, 8) - : new THREE.BoxGeometry(0.9, 0.75, 0.9); - const material = new THREE.MeshPhongMaterial({ - color: isTree ? 0x2e8b57 : 0x6f7b86, - }); - const mesh = new THREE.Mesh(geometry, material); - mesh.position.set(resource.position.x, isTree ? 0.55 : 0.38, resource.position.y); + let mesh; + if (isTree) { + // Trunk + const trunkGeo = new THREE.CylinderGeometry(0.1, 0.14, 0.55, 7); + const trunkMat = new THREE.MeshPhongMaterial({ color: 0x8b5e3c }); + const trunk = new THREE.Mesh(trunkGeo, trunkMat); + trunk.position.y = 0.28; + trunk.castShadow = true; + + // Canopy + const canopyGeo = new THREE.ConeGeometry(0.52, 1.2, 8); + const canopyMat = new THREE.MeshPhongMaterial({ color: 0x2e8b57 }); + const canopy = new THREE.Mesh(canopyGeo, canopyMat); + canopy.position.y = 1.15; // sits on top of trunk + canopy.castShadow = true; + + const group = new THREE.Group(); + group.add(trunk); + group.add(canopy); + group.position.set(resource.position.x, 0, resource.position.y); + group.receiveShadow = true; + scene.add(group); + resourceObjects[resource.id] = group; + return; + } + + // Mountain / rock + const geometry = new THREE.DodecahedronGeometry(0.52, 0); + const material = new THREE.MeshPhongMaterial({ color: 0x6f7b86 }); + mesh = new THREE.Mesh(geometry, material); + mesh.position.set(resource.position.x, 0.52, resource.position.y); + mesh.rotation.set(Math.random() * 0.4, Math.random() * Math.PI, Math.random() * 0.4); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); @@ -875,14 +1159,26 @@ if (!monsterCubes[monster.id]) { createMonsterCube(monster); } else { - monsterCubes[monster.id].position.set(monster.position.x, 0.3, monster.position.y); + monsterCubes[monster.id].mesh.position.set(monster.position.x, 0.3, monster.position.y); + monsterCubes[monster.id].label.sprite.position.set(monster.position.x, 1.25, monster.position.y); + monsterCubes[monster.id].hpBar.sprite.position.set(monster.position.x, 0.95, monster.position.y); + updateTextSprite( + monsterCubes[monster.id].label, + `Lv ${monster.level} HP ${Math.max(0, monster.health)}/${monster.max_health}` + ); + updateProgressBarSprite( + monsterCubes[monster.id].hpBar, + monster.max_health > 0 ? monster.health / monster.max_health : 0 + ); } } // Remove deleted monsters for (const mid in monsterCubes) { if (!(gameState.monsters || {})[mid]) { - scene.remove(monsterCubes[mid]); + scene.remove(monsterCubes[mid].mesh); + scene.remove(monsterCubes[mid].label.sprite); + scene.remove(monsterCubes[mid].hpBar.sprite); delete monsterCubes[mid]; } } @@ -919,7 +1215,15 @@ const chests = gameState.chests || {}; for (const chest of Object.values(chests)) { - if (!chestObjects[chest.id]) createChestMesh(chest); + if (chest.opened) { + // Remove from scene as soon as it's marked opened + if (chestObjects[chest.id]) { + scene.remove(chestObjects[chest.id]); + delete chestObjects[chest.id]; + } + } else if (!chestObjects[chest.id]) { + createChestMesh(chest); + } } for (const cid in chestObjects) { if (!chests[cid]) { @@ -935,11 +1239,28 @@ function animate() { requestAnimationFrame(animate); + const now = performance.now(); + const deltaTime = Math.min((now - previousFrameTime) / 1000, 0.05); + previousFrameTime = now; updateGameScene(); + updateWorldEffects(deltaTime); if (controls) controls.update(); renderer.render(scene, camera); } + function followCameraByPlayerDelta(previousPosition, nextPosition) { + if (!camera || !controls || !cameraFollowOnMove || !previousPosition || !nextPosition) return; + const deltaX = (nextPosition.x ?? 0) - (previousPosition.x ?? 0); + const deltaZ = (nextPosition.y ?? 0) - (previousPosition.y ?? 0); + if (Math.abs(deltaX) < 0.0001 && Math.abs(deltaZ) < 0.0001) return; + + controls.target.x += deltaX; + controls.target.z += deltaZ; + camera.position.x += deltaX; + camera.position.z += deltaZ; + controls.update(); + } + // ==================== API FUNCTIONS ==================== async function login(username, color) { try { @@ -976,8 +1297,12 @@ const data = JSON.parse(event.data); if (data.type === 'state_update') { + const previousPosition = currentPlayer ? { ...currentPlayer.position } : null; gameState = data; currentPlayer = gameState.players[currentPlayerId] || currentPlayer; + if (currentPlayer && previousPosition) { + followCameraByPlayerDelta(previousPosition, currentPlayer.position); + } document.getElementById('timeRemaining').textContent = formatTime(data.time_remaining); @@ -1011,6 +1336,7 @@ async function movePlayer(dx, dy) { try { + const previousPosition = currentPlayer ? { ...currentPlayer.position } : null; const response = await fetch(`${API_BASE}/player/${currentPlayerId}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1028,6 +1354,7 @@ currentPlayer.position = data.position; currentPlayer.action_points = data.action_points ?? currentPlayer.action_points; updatePlayerSphere(currentPlayer); + followCameraByPlayerDelta(previousPosition, currentPlayer.position); focusCameraOnPlayer(false); updateUI(); } @@ -1052,6 +1379,38 @@ } else { showMessage(`Action failed: ${data.reason || 'Unknown error'}`); } + + if (data.success && currentPlayer) { + if (typeof data.player_health === 'number') { + currentPlayer.health = data.player_health; + } + if (data.respawn_position) { + currentPlayer.position = data.respawn_position; + updatePlayerSphere(currentPlayer); + focusCameraOnPlayer(true); + } + + if (actionType === 'gather' && data.gathered) { + const wood = data.gathered.wood || 0; + const stone = data.gathered.stone || 0; + const parts = []; + if (wood > 0) parts.push(`+${wood} wood`); + if (stone > 0) parts.push(`+${stone} stone`); + if (parts.length > 0) { + spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0x66ff88); + spawnFloatingText(parts.join(' '), currentPlayer.position.x, currentPlayer.position.y, '#9cff9c'); + } + } + if (actionType === 'attack') { + if (typeof data.damage === 'number') { + spawnFloatingText(`-${data.damage} HP`, currentPlayer.position.x, currentPlayer.position.y, '#ff9999'); + } + if (typeof data.retaliation_damage === 'number' && data.retaliation_damage > 0) { + spawnGroundPulse(currentPlayer.position.x, currentPlayer.position.y, 0xff6666); + spawnFloatingText(`Retaliated: -${data.retaliation_damage}`, currentPlayer.position.x, currentPlayer.position.y, '#ff7777'); + } + } + } return data; } catch (error) { console.error('Action failed:', error); @@ -1122,7 +1481,7 @@ }); } - document.getElementById('attackBtn').onclick = () => { + document.getElementById('attackBtn').onclick = async () => { if (!gameState || !currentPlayer) { showMessage('No targets available'); return; @@ -1150,11 +1509,19 @@ showMessage(`No target in range (${range.toFixed(1)})`); return; } - performAction('attack', nearest.id); + spawnGroundPulse(nearest.position.x, nearest.position.y, 0xffaa66); + const result = await performAction('attack', nearest.id); + if (result && result.success && typeof result.damage === 'number') { + spawnFloatingText(`-${result.damage}`, nearest.position.x, nearest.position.y, '#ffb0b0'); + } }; - document.getElementById('gatherBtn').onclick = () => { - performAction('gather', null); + document.getElementById('gatherBtn').onclick = async () => { + const data = await performAction('gather', null); + if (data && data.success && data.inventory && currentPlayer) { + currentPlayer.inventory = data.inventory; + updateUI(); + } }; document.getElementById('buildHouseBtn').onclick = () => { @@ -1176,22 +1543,31 @@ document.getElementById('lootBtn').onclick = async () => { const data = await performAction('loot'); if (data && data.success && typeof data.slot_index === 'number') { - await performAction('equip', null, null, null, null, data.slot_index); + if (data.weapon_slots) currentPlayer.weapon_slots = data.weapon_slots; + const equipData = await performAction('equip', null, null, null, null, data.slot_index); + if (equipData && equipData.success) { + currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot; + showMessage(`Equipped: ${data.weapon ? data.weapon.name : 'weapon'}!`); + } + updateUI(); } }; - document.getElementById('equipBtn').onclick = () => { - if (!currentPlayer || !Array.isArray(currentPlayer.weapon_slots)) { - showMessage('No weapon slots available'); - return; + // Click a weapon in the inventory panel to equip it (no AP cost). + document.getElementById('weaponSlots').addEventListener('click', async (e) => { + const targetEl = e.target instanceof Element ? e.target : e.target?.parentElement; + if (!targetEl) return; + const div = targetEl.closest('[data-slot]'); + if (!div) return; + const slotIndex = parseInt(div.dataset.slot); + if (Number.isNaN(slotIndex)) return; + const equipData = await performAction('equip', null, null, null, null, slotIndex); + if (equipData && equipData.success) { + currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot; + showMessage(`Equipped: ${currentPlayer.weapon_slots[slotIndex]?.name}`); + updateUI(); } - const slot = currentPlayer.weapon_slots.findIndex(w => w); - if (slot < 0) { - showMessage('No weapon to equip'); - return; - } - performAction('equip', null, null, null, null, slot); - }; + }); } // ==================== LOGIN FORM ====================