feat: revise monster scaling
This commit is contained in:
+42
-29
@@ -338,49 +338,50 @@ class GameWorld:
|
|||||||
player.last_hp_regen = current_time
|
player.last_hp_regen = current_time
|
||||||
|
|
||||||
def _spawn_monster(self):
|
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}"
|
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)
|
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||||
y = 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(
|
self.monsters[monster_id] = Monster(
|
||||||
id=monster_id,
|
id=monster_id,
|
||||||
position=Position(x, y, 0),
|
position=Position(x, y, 0),
|
||||||
health=health,
|
health=health,
|
||||||
max_health=health,
|
max_health=health,
|
||||||
level=level,
|
level=level,
|
||||||
attack=3 + level,
|
attack=2 + level,
|
||||||
defense=1 + level // 2,
|
defense=1 + level // 3,
|
||||||
is_boss=False,
|
is_boss=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _spawn_boss(self):
|
def _spawn_boss(self):
|
||||||
"""Spawn the boss monster"""
|
"""Spawn the boss monster"""
|
||||||
boss_id = f"boss_{self.generation}"
|
boss_id = f"boss_{self.generation}"
|
||||||
active_players = [p for p in self.players.values() if p.active]
|
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)
|
avg_level = sum(p.level for p in active_players) / len(active_players)
|
||||||
health = int(BOSS_HEALTH_BASE + avg_level * 500)
|
health = int(BOSS_HEALTH_BASE + avg_level * 500)
|
||||||
|
|
||||||
# Spawn boss at a distance from players
|
# Spawn boss at a distance from players
|
||||||
if active_players:
|
if active_players:
|
||||||
player_pos = active_players[0].position
|
player_pos = active_players[0].position
|
||||||
angle = random.random() * 2 * 3.14159
|
angle = random.random() * 2 * 3.14159
|
||||||
x = player_pos.x + BOSS_SPAWN_DISTANCE * (angle ** 0.5) * (1 if random.random() > 0.5 else -1)
|
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)
|
y = player_pos.y + BOSS_SPAWN_DISTANCE * (1 - angle ** 0.5) * (1 if random.random() > 0.5 else -1)
|
||||||
else:
|
else:
|
||||||
x = y = 0
|
x = y = 0
|
||||||
|
|
||||||
self.boss = Monster(
|
self.boss = Monster(
|
||||||
id=boss_id,
|
id=boss_id,
|
||||||
position=Position(x, y, 0),
|
position=Position(x, y, 0),
|
||||||
health=health,
|
health=health,
|
||||||
max_health=health,
|
max_health=health,
|
||||||
level=int(avg_level) + 5,
|
level=int(avg_level) + 5,
|
||||||
attack=15 + int(avg_level),
|
attack=15 + int(avg_level),
|
||||||
defense=4 + int(avg_level // 2),
|
defense=4 + int(avg_level // 2),
|
||||||
is_boss=True,
|
is_boss=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _player_effective_attack(player: Player) -> int:
|
def _player_effective_attack(player: Player) -> int:
|
||||||
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
|
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:
|
if monster.health <= 0:
|
||||||
del game_world.monsters[target_id]
|
del game_world.monsters[target_id]
|
||||||
player.exp += monster.level * 10
|
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}
|
return {"success": True, "damage": damage, "exp": monster.level * 10, "monster_killed": True}
|
||||||
|
|
||||||
retaliation = apply_retaliation(monster.attack)
|
retaliation = apply_retaliation(monster.attack)
|
||||||
@@ -934,3 +941,9 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+119
-90
@@ -560,16 +560,28 @@
|
|||||||
setTimeout(() => el.classList.remove('flash'), 600);
|
setTimeout(() => el.classList.remove('flash'), 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
if (!currentPlayer) return;
|
if (!currentPlayer) return;
|
||||||
|
|
||||||
document.getElementById('statLevel').textContent = currentPlayer.level;
|
document.getElementById('statLevel').textContent = currentPlayer.level;
|
||||||
document.getElementById('statExp').textContent = currentPlayer.exp;
|
document.getElementById('statExp').textContent = currentPlayer.exp;
|
||||||
const levelProgress = ((currentPlayer.exp || 0) % 100);
|
|
||||||
document.getElementById('expBar').style.width = `${Math.max(0, Math.min(100, levelProgress))}%`;
|
// Calculate XP progress for new level: 30 for level 1, +20 per level after
|
||||||
document.getElementById('statHealth').textContent = `${currentPlayer.health}/${currentPlayer.max_health}`;
|
// XP needed to reach this level: 30 + 20*(level-2) for level >= 2, or 0 for level 1
|
||||||
document.getElementById('healthBar').style.width = `${(currentPlayer.health / currentPlayer.max_health) * 100}%`;
|
const currentLevel = currentPlayer.level;
|
||||||
document.getElementById('statActionPoints').textContent = currentPlayer.action_points;
|
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('actionBar').style.width = `${(currentPlayer.action_points / currentPlayer.max_action_points) * 100}%`;
|
||||||
document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack;
|
document.getElementById('statAttack').textContent = currentPlayer.effective_attack ?? currentPlayer.attack;
|
||||||
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
|
document.getElementById('statRange').textContent = (currentPlayer.attack_range ?? 4).toFixed(1);
|
||||||
@@ -605,40 +617,40 @@
|
|||||||
if (newWood !== prevWood) flashEl('invWood');
|
if (newWood !== prevWood) flashEl('invWood');
|
||||||
if (newStone !== prevStone) flashEl('invStone');
|
if (newStone !== prevStone) flashEl('invStone');
|
||||||
|
|
||||||
// Weapon slots
|
// Weapon slots
|
||||||
const slots = currentPlayer.weapon_slots || [];
|
const slots = currentPlayer.weapon_slots || [];
|
||||||
const equippedIdx = currentPlayer.equipped_weapon_slot ?? -1;
|
const equippedIdx = currentPlayer.equipped_weapon_slot ?? -1;
|
||||||
const slotEl = document.getElementById('weaponSlots');
|
const slotEl = document.getElementById('weaponSlots');
|
||||||
if (slotEl) {
|
if (slotEl) {
|
||||||
const filled = slots.filter(Boolean);
|
const filled = slots.filter(Boolean);
|
||||||
if (filled.length === 0) {
|
if (filled.length === 0) {
|
||||||
slotEl.innerHTML = '<span style="color:#555">No weapons</span>';
|
slotEl.innerHTML = '<span style="color:#555">No weapons</span>';
|
||||||
} else {
|
} else {
|
||||||
slotEl.innerHTML = slots.map((w, i) => {
|
slotEl.innerHTML = slots.map((w, i) => {
|
||||||
if (!w) return '';
|
if (!w) return '';
|
||||||
const eq = i === equippedIdx;
|
const eq = i === equippedIdx;
|
||||||
const border = eq ? 'border:1px solid #f1c40f' : 'border:1px solid #444';
|
const border = eq ? 'border:1px solid #f1c40f' : 'border:1px solid #444';
|
||||||
const bg = eq ? 'background:#2a2600' : 'background:#1e1e1e';
|
const bg = eq ? 'background:#2a2600' : 'background:#1e1e1e';
|
||||||
const icon = eq ? '⚔️ ' : '· ';
|
const icon = eq ? '⚔️ ' : '· ';
|
||||||
const aura = w.aura_bonuses || {};
|
const aura = w.aura_bonuses || {};
|
||||||
const auraParts = [];
|
const auraParts = [];
|
||||||
if (aura.action_regen) auraParts.push(`+${aura.action_regen} AP regen`);
|
if (aura.action_regen) auraParts.push(`+${aura.action_regen} AP regen`);
|
||||||
if (aura.hp_regen) auraParts.push(`+${aura.hp_regen} HP regen`);
|
if (aura.hp_regen) auraParts.push(`+${aura.hp_regen} HP regen`);
|
||||||
if (aura.defense) auraParts.push(`+${aura.defense} def`);
|
if (aura.defense) auraParts.push(`+${aura.defense} def`);
|
||||||
if (aura.max_health) auraParts.push(`+${aura.max_health} max HP`);
|
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`);
|
if (aura.free_move_chance) auraParts.push(`${Math.round(aura.free_move_chance * 100)}% free moves`);
|
||||||
const auraLine = auraParts.length > 0
|
const auraLine = auraParts.length > 0
|
||||||
? `<div style="color:#7fd6ff; margin-top:2px;">Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}</div>`
|
? `<div style="color:#7fd6ff; margin-top:2px; pointer-events:none;">Aura (${w.aura_radius || 0}): ${auraParts.join(', ')}</div>`
|
||||||
: '';
|
: '';
|
||||||
return `<div data-slot="${i}" style="padding:3px 6px;margin:2px 0;border-radius:4px;${border};${bg};cursor:pointer;"
|
return `<div data-slot="${i}" style="padding:3px 6px;margin:2px 0;border-radius:4px;${border};${bg};cursor:pointer;"
|
||||||
title="Click to equip">
|
title="Click to equip" class="weapon-slot">
|
||||||
${icon}<b>${w.name}</b>
|
${icon}<b>${w.name}</b>
|
||||||
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng</span>
|
<span style="color:#aaa"> +${w.attack_bonus}atk +${w.range_bonus}rng</span>
|
||||||
${auraLine}
|
${auraLine}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameState) {
|
if (gameState) {
|
||||||
const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length;
|
const activePlayerCount = Object.values(gameState.players).filter(p => p.active).length;
|
||||||
@@ -808,22 +820,26 @@
|
|||||||
controls.update();
|
controls.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlayerSphere(player) {
|
function createPlayerSphere(player) {
|
||||||
const geometry = new THREE.SphereGeometry(0.5, 16, 16);
|
const geometry = new THREE.SphereGeometry(0.5, 16, 16);
|
||||||
const isCurrentPlayer = player.id === currentPlayerId;
|
const isCurrentPlayer = player.id === currentPlayerId;
|
||||||
const material = new THREE.MeshPhongMaterial({
|
const material = new THREE.MeshPhongMaterial({
|
||||||
color: player.color,
|
color: player.color,
|
||||||
emissive: isCurrentPlayer ? 0x222222 : 0x000000,
|
emissive: isCurrentPlayer ? 0x222222 : 0x000000,
|
||||||
shininess: isCurrentPlayer ? 80 : 30,
|
shininess: isCurrentPlayer ? 80 : 30,
|
||||||
});
|
});
|
||||||
const sphere = new THREE.Mesh(geometry, material);
|
const sphere = new THREE.Mesh(geometry, material);
|
||||||
sphere.position.set(player.position.x, 0.5, player.position.y);
|
sphere.position.set(player.position.x, 0.5, player.position.y);
|
||||||
if (isCurrentPlayer) {
|
// Scale sphere based on level: 1.0 at level 1, increases with level
|
||||||
sphere.scale.set(1.15, 1.15, 1.15);
|
const levelScale = 1.0 + (player.level - 1) * 0.08;
|
||||||
}
|
sphere.scale.set(levelScale, levelScale, levelScale);
|
||||||
sphere.castShadow = true;
|
if (isCurrentPlayer) {
|
||||||
sphere.receiveShadow = true;
|
// Current player slightly larger
|
||||||
scene.add(sphere);
|
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
|
// Compact nameplate — canvas is exactly as tall as the pill so no
|
||||||
// transparent headroom inflates the visible sprite quad.
|
// transparent headroom inflates the visible sprite quad.
|
||||||
@@ -880,16 +896,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlayerSphere(player) {
|
function updatePlayerSphere(player) {
|
||||||
if (playerSpheres[player.id]) {
|
if (playerSpheres[player.id]) {
|
||||||
playerSpheres[player.id].mesh.position.set(player.position.x, 0.5, player.position.y);
|
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);
|
// Update scale based on level
|
||||||
if (playerSpheres[player.id].northArrow) {
|
const levelScale = 1.0 + (player.level - 1) * 0.08;
|
||||||
playerSpheres[player.id].northArrow.position.set(player.position.x, 0.5, player.position.y - 1.1);
|
const baseScale = player.id === currentPlayerId ? levelScale * 1.15 : levelScale;
|
||||||
}
|
playerSpheres[player.id].mesh.scale.set(baseScale, baseScale, baseScale);
|
||||||
setPlayerVisualState(player);
|
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)') {
|
function createTextSprite(initialText, textColor = '#ffffff', bgColor = 'rgba(0, 0, 0, 0.7)') {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -966,13 +986,19 @@
|
|||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMonsterCube(monster) {
|
function createMonsterCube(monster) {
|
||||||
const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
|
const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
|
||||||
const material = new THREE.MeshPhongMaterial({ color: 0xff4444 });
|
// Color by level: green (1-3), yellow (4-6), orange (7-9), red (10+)
|
||||||
const cube = new THREE.Mesh(geometry, material);
|
let monsterColor = 0xff4444; // red default
|
||||||
cube.position.set(monster.position.x, 0.3, monster.position.y);
|
if (monster.level <= 3) monsterColor = 0x44ff44; // green
|
||||||
cube.castShadow = true;
|
else if (monster.level <= 6) monsterColor = 0xffff44; // yellow
|
||||||
scene.add(cube);
|
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');
|
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);
|
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).
|
// Click a weapon in the inventory panel to equip it (no AP cost).
|
||||||
document.getElementById('weaponSlots').addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
const targetEl = e.target instanceof Element ? e.target : e.target?.parentElement;
|
const weaponSlot = e.target.closest('.weapon-slot');
|
||||||
if (!targetEl) return;
|
if (!weaponSlot) return;
|
||||||
const div = targetEl.closest('[data-slot]');
|
|
||||||
if (!div) return;
|
const slotIndex = parseInt(weaponSlot.getAttribute('data-slot'), 10);
|
||||||
const slotIndex = parseInt(div.dataset.slot);
|
if (!Number.isFinite(slotIndex) || slotIndex < 0) return;
|
||||||
if (Number.isNaN(slotIndex)) return;
|
if (!currentPlayer.weapon_slots || !currentPlayer.weapon_slots[slotIndex]) return;
|
||||||
const equipData = await performAction('equip', null, null, null, null, slotIndex);
|
|
||||||
if (equipData && equipData.success) {
|
const weaponName = currentPlayer.weapon_slots[slotIndex]?.name || 'weapon';
|
||||||
currentPlayer.equipped_weapon_slot = equipData.equipped_weapon_slot;
|
const equipData = await performAction('equip', null, null, null, null, slotIndex);
|
||||||
showMessage(`Equipped: ${currentPlayer.weapon_slots[slotIndex]?.name}`);
|
|
||||||
updateUI();
|
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 ====================
|
// ==================== LOGIN FORM ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user