From 085678955d3148db27f3c2ced90fc572f9be36aa Mon Sep 17 00:00:00 2001 From: Erzangel Date: Fri, 29 May 2026 17:38:51 +0200 Subject: [PATCH] feat: revise monster scaling --- backend.py | 71 ++++++++++-------- index.html | 209 ++++++++++++++++++++++++++++++----------------------- 2 files changed, 161 insertions(+), 119 deletions(-) diff --git a/backend.py b/backend.py index c6a7c43..8ec0aa5 100644 --- a/backend.py +++ b/backend.py @@ -338,49 +338,50 @@ class GameWorld: player.last_hp_regen = current_time def _spawn_monster(self): - """Spawn a random monster on the map""" + """Spawn a random monster on the map with level 1-10""" monster_id = f"monster_{len(self.monsters)}_{self.generation}" - level = random.randint(1, 5) + level = random.randint(1, 10) x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2) - health = 20 + level * 10 + # HP scales with level: base 15 + 5 per level = 20-65 for levels 1-10 + health = 15 + level * 5 self.monsters[monster_id] = Monster( id=monster_id, position=Position(x, y, 0), health=health, max_health=health, level=level, - attack=3 + level, - defense=1 + level // 2, + attack=2 + level, + defense=1 + level // 3, 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 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 + # 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), - defense=4 + int(avg_level // 2), - is_boss=True, - ) + 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), + defense=4 + int(avg_level // 2), + is_boss=True, + ) def _player_effective_attack(player: Player) -> int: if 0 <= player.equipped_weapon_slot < len(player.weapon_slots): @@ -699,7 +700,13 @@ def _handle_attack(player: Player, target_id: str) -> Dict: if monster.health <= 0: del game_world.monsters[target_id] player.exp += monster.level * 10 - player.level = 1 + int(player.exp / 100) + # Calculate level based on XP: 30 for level 1, +20 per additional level + # Level N requires: 30 + 20*(N-1) total XP + player.level = 1 + xp_needed = 30 + while player.exp >= xp_needed and player.level < 100: + player.level += 1 + xp_needed += 20 return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True} retaliation = apply_retaliation(monster.attack) @@ -934,3 +941,9 @@ async def health(): return {"status": "ok"} + + + + + + diff --git a/index.html b/index.html index 93bc000..b21c709 100644 --- a/index.html +++ b/index.html @@ -560,16 +560,28 @@ setTimeout(() => el.classList.remove('flash'), 600); } - function updateUI() { - if (!currentPlayer) return; + 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('statLevel').textContent = currentPlayer.level; + document.getElementById('statExp').textContent = currentPlayer.exp; + + // Calculate XP progress for new level: 30 for level 1, +20 per level after + // XP needed to reach this level: 30 + 20*(level-2) for level >= 2, or 0 for level 1 + const currentLevel = currentPlayer.level; + let xpForCurrentLevel = 0; + let xpForNextLevel = 30; + if (currentLevel > 1) { + xpForCurrentLevel = 30 + 20 * (currentLevel - 2); + xpForNextLevel = 30 + 20 * (currentLevel - 1); + } + const xpIntoLevel = currentPlayer.exp - xpForCurrentLevel; + const xpNeededForLevel = xpForNextLevel - xpForCurrentLevel; + const levelProgress = Math.max(0, Math.min(100, (xpIntoLevel / xpNeededForLevel) * 100)); + document.getElementById('expBar').style.width = `${levelProgress}%`; + document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`; + document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`; + document.getElementById('statActionPoints').textContent = currentPlayer.action_points; document.getElementById('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`; document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack; document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1); @@ -605,40 +617,40 @@ 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(''); - } - } + // 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; @@ -808,22 +820,26 @@ 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); + function createPlayerSphere(player) { + const geometry = new THREE.SphereGeometry(0.5, 16, 16); + const isCurrentPlayer = player.id === currentPlayerId; + const material = new THREE.MeshPhongMaterial({ + color: player.color, + emissive: isCurrentPlayer ? 0x222222 : 0x000000, + shininess: isCurrentPlayer ? 80 : 30, + }); + const sphere = new THREE.Mesh(geometry, material); + sphere.position.set(player.position.x, 0.5, player.position.y); + // Scale sphere based on level: 1.0 at level 1, increases with level + const levelScale = 1.0 + (player.level - 1) * 0.08; + sphere.scale.set(levelScale, levelScale, levelScale); + if (isCurrentPlayer) { + // Current player slightly larger + sphere.scale.multiplyScalar(1.15); + } + sphere.castShadow = true; + sphere.receiveShadow = true; + scene.add(sphere); // Compact nameplate — canvas is exactly as tall as the pill so no // transparent headroom inflates the visible sprite quad. @@ -880,16 +896,20 @@ } } - function updatePlayerSphere(player) { - if (playerSpheres[player.id]) { - playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y); - playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y); - if (playerSpheres[player.id].northArrow) { - playerSpheres[player.id].northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1); - } - setPlayerVisualState(player); - } - } + function updatePlayerSphere(player) { + if (playerSpheres[player.id]) { + playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y); + // Update scale based on level + const levelScale = 1.0 + (player.level - 1) * 0.08; + const baseScale = player.id === currentPlayerId ? levelScale * 1.15 : levelScale; + playerSpheres[player.id].mesh.scale.set(baseScale, baseScale, baseScale); + playerSpheres[player.id].sprite.position.set(player.position.x, 2.5, player.position.y); + if (playerSpheres[player.id].northArrow) { + playerSpheres[player.id].northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1); + } + setPlayerVisualState(player); + } + } function createTextSprite(initialText, textColor = '#ffffff', bgColor = 'rgba(0, 0, 0, 0.7)') { const canvas = document.createElement('canvas'); @@ -966,13 +986,19 @@ texture.needsUpdate = true; } - 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); + function createMonsterCube(monster) { + const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6); + // Color by level: green (1-3), yellow (4-6), orange (7-9), red (10+) + let monsterColor = 0xff4444; // red default + if (monster.level <= 3) monsterColor = 0x44ff44; // green + else if (monster.level <= 6) monsterColor = 0xffff44; // yellow + else if (monster.level <= 9) monsterColor = 0xff8844; // orange + else monsterColor = 0xff4444; // red for 10+ + const material = new THREE.MeshPhongMaterial({ color: monsterColor }); + const cube = new THREE.Mesh(geometry, material); + cube.position.set(monster.position.x, 0.3, monster.position.y); + cube.castShadow = true; + scene.add(cube); const label = createTextSprite(`Lv ${monster.level} HP ${monster.health}/${monster.max_health}`, '#ffdede'); label.sprite.position.set(monster.position.x, 1.25, monster.position.y); @@ -1554,20 +1580,23 @@ }; // 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(); - } - }); + document.addEventListener('click', async (e) => { + const weaponSlot = e.target.closest('.weapon-slot'); + if (!weaponSlot) return; + + const slotIndex = parseInt(weaponSlot.getAttribute('data-slot'), 10); + if (!Number.isFinite(slotIndex) || slotIndex < 0) return; + if (!currentPlayer.weapon_slots || !currentPlayer.weapon_slots[slotIndex]) return; + + const weaponName = currentPlayer.weapon_slots[slotIndex]?.name || 'weapon'; + const equipData = await performAction('equip', null, null, null, null, slotIndex); + + if (equipData && equipData.success) { + currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot; + showMessage(`Equipped: ${weaponName}`); + updateUI(); + } + }, true); // Use capture phase for maximum reliability } // ==================== LOGIN FORM ====================