feat: more items
This commit is contained in:
+194
-36
@@ -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()},
|
||||
|
||||
Reference in New Issue
Block a user