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 ====================