feat: progress
This commit is contained in:
+199
-23
@@ -24,6 +24,14 @@ MAX_MONSTERS = 50
|
||||
BOSS_HEALTH_BASE = 1000
|
||||
MIN_LEVEL_FOR_BOSS = 10
|
||||
BOSS_STRUCTURE_BONUS_MULTIPLIER = 0.1
|
||||
RESOURCE_NODE_COUNT = 180
|
||||
CHEST_SPAWN_RATE = 0.015
|
||||
MAX_CHESTS = 15
|
||||
CHEST_LOOT_RANGE = 3
|
||||
ATTACK_RANGE_BASE = 4
|
||||
WEAPON_SLOT_COUNT = 5
|
||||
BLOCK_BUILD_COST_WOOD = 2
|
||||
BLOCK_HEALTH = 5
|
||||
|
||||
# Data Models
|
||||
@dataclass
|
||||
@@ -57,10 +65,18 @@ class Player:
|
||||
defense: int = 2
|
||||
movement_capacity: int = 1
|
||||
gathering_capacity: int = 10
|
||||
attack_range: float = ATTACK_RANGE_BASE
|
||||
weapon_slots: List[Dict] = field(default_factory=lambda: [None] * WEAPON_SLOT_COUNT)
|
||||
equipped_weapon_slot: int = -1
|
||||
active: bool = True
|
||||
session_id: str = ""
|
||||
|
||||
def to_dict(self):
|
||||
equipped_weapon = None
|
||||
if 0 <= self.equipped_weapon_slot < len(self.weapon_slots):
|
||||
equipped_weapon = self.weapon_slots[self.equipped_weapon_slot]
|
||||
attack_bonus = equipped_weapon.get("attack_bonus", 0) if equipped_weapon else 0
|
||||
range_bonus = equipped_weapon.get("range_bonus", 0) if equipped_weapon else 0
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
@@ -74,9 +90,13 @@ class Player:
|
||||
"max_action_points": self.max_action_points,
|
||||
"inventory": self.inventory,
|
||||
"attack": self.attack,
|
||||
"effective_attack": self.attack + attack_bonus,
|
||||
"attack_range": self.attack_range + range_bonus,
|
||||
"defense": self.defense,
|
||||
"movement_capacity": self.movement_capacity,
|
||||
"gathering_capacity": self.gathering_capacity,
|
||||
"weapon_slots": self.weapon_slots,
|
||||
"equipped_weapon_slot": self.equipped_weapon_slot,
|
||||
"active": self.active,
|
||||
}
|
||||
|
||||
@@ -88,6 +108,7 @@ class Monster:
|
||||
max_health: int
|
||||
level: int
|
||||
attack: int
|
||||
defense: int = 1
|
||||
is_boss: bool = False
|
||||
boss_progress: float = 0.0 # Percentage of damage done
|
||||
|
||||
@@ -99,10 +120,28 @@ class Monster:
|
||||
"max_health": self.max_health,
|
||||
"level": self.level,
|
||||
"attack": self.attack,
|
||||
"defense": self.defense,
|
||||
"is_boss": self.is_boss,
|
||||
"boss_progress": self.boss_progress,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class Chest:
|
||||
id: str
|
||||
position: Position
|
||||
opened: bool = False
|
||||
rarity: str = "rare"
|
||||
weapon: Dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"position": self.position.to_dict(),
|
||||
"opened": self.opened,
|
||||
"rarity": self.rarity,
|
||||
"weapon": self.weapon,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class Structure:
|
||||
id: str
|
||||
@@ -143,16 +182,18 @@ class GameWorld:
|
||||
self.monsters: Dict[str, Monster] = {}
|
||||
self.structures: Dict[str, Structure] = {}
|
||||
self.resources: Dict[str, Resource] = {}
|
||||
self.chests: Dict[str, Chest] = {}
|
||||
self.boss: Monster = None
|
||||
self.game_start_time: float = 0
|
||||
self.is_game_active: bool = False
|
||||
self.connected_sessions: Dict[str, str] = {} # session_id -> player_id
|
||||
self.generation = 0
|
||||
self._init_resources()
|
||||
self._init_chests()
|
||||
|
||||
def _init_resources(self):
|
||||
"""Initialize static resources on the map"""
|
||||
for _ in range(50):
|
||||
for _ in range(RESOURCE_NODE_COUNT):
|
||||
resource_id = f"resource_{len(self.resources)}"
|
||||
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||
@@ -164,12 +205,37 @@ class GameWorld:
|
||||
amount=random.randint(50, 200),
|
||||
)
|
||||
|
||||
def _init_chests(self):
|
||||
"""Spawn rare chests with weapon loot."""
|
||||
for _ in range(8):
|
||||
self._spawn_chest()
|
||||
|
||||
def _spawn_chest(self):
|
||||
chest_id = f"chest_{len(self.chests)}_{self.generation}_{random.randint(100, 999)}"
|
||||
x = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||
y = random.randint(-GRID_SIZE // 2, GRID_SIZE // 2)
|
||||
weapon_templates = [
|
||||
{"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},
|
||||
]
|
||||
self.chests[chest_id] = Chest(
|
||||
id=chest_id,
|
||||
position=Position(x, y, 0),
|
||||
weapon=random.choice(weapon_templates),
|
||||
)
|
||||
|
||||
def start_game(self):
|
||||
"""Start or restart the game"""
|
||||
self.game_start_time = datetime.now().timestamp()
|
||||
self.is_game_active = True
|
||||
self.boss = None
|
||||
self.monsters.clear()
|
||||
self.chests.clear()
|
||||
self.resources.clear()
|
||||
self.structures.clear()
|
||||
self._init_resources()
|
||||
self._init_chests()
|
||||
self.generation += 1
|
||||
# Keep players but reset their state
|
||||
for player in self.players.values():
|
||||
@@ -180,6 +246,9 @@ class GameWorld:
|
||||
player.health = player.max_health
|
||||
player.action_points = ACTION_POINTS_MAX
|
||||
player.inventory = {"wood": 0, "stone": 0}
|
||||
player.weapon_slots = [None] * WEAPON_SLOT_COUNT
|
||||
player.equipped_weapon_slot = -1
|
||||
player.attack_range = ATTACK_RANGE_BASE
|
||||
player.position = Position(
|
||||
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||
random.randint(-PLAYER_START_SPAWN_RANGE, PLAYER_START_SPAWN_RANGE),
|
||||
@@ -214,6 +283,10 @@ class GameWorld:
|
||||
if random.random() < MONSTER_SPAWN_RATE and len(self.monsters) < MAX_MONSTERS:
|
||||
self._spawn_monster()
|
||||
|
||||
# Spawn rare chests over time
|
||||
if random.random() < CHEST_SPAWN_RATE and len(self.chests) < MAX_CHESTS:
|
||||
self._spawn_chest()
|
||||
|
||||
# Regenerate player action points
|
||||
for player in self.players.values():
|
||||
if not player.active:
|
||||
@@ -243,6 +316,7 @@ class GameWorld:
|
||||
max_health=health,
|
||||
level=level,
|
||||
attack=3 + level,
|
||||
defense=1 + level // 2,
|
||||
is_boss=False,
|
||||
)
|
||||
|
||||
@@ -269,17 +343,34 @@ class GameWorld:
|
||||
max_health=health,
|
||||
level=int(avg_level) + 5,
|
||||
attack=15 + int(avg_level),
|
||||
defense=4 + int(avg_level // 2),
|
||||
is_boss=True,
|
||||
)
|
||||
|
||||
def _get_nearby_structure_bonuses(self, position: Position, radius: int = 10) -> Dict:
|
||||
bonuses = {}
|
||||
for structure in self.structures.values():
|
||||
dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5
|
||||
if dist <= radius:
|
||||
for key, value in structure.bonuses.items():
|
||||
bonuses[key] = bonuses.get(key, 0) + value
|
||||
return bonuses
|
||||
def _player_effective_attack(player: Player) -> int:
|
||||
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
|
||||
w = player.weapon_slots[player.equipped_weapon_slot]
|
||||
if w:
|
||||
return player.attack + int(w.get("attack_bonus", 0))
|
||||
return player.attack
|
||||
|
||||
def _player_effective_range(player: Player) -> float:
|
||||
if 0 <= player.equipped_weapon_slot < len(player.weapon_slots):
|
||||
w = player.weapon_slots[player.equipped_weapon_slot]
|
||||
if w:
|
||||
return player.attack_range + float(w.get("range_bonus", 0))
|
||||
return player.attack_range
|
||||
|
||||
def _get_nearby_structure_bonuses_impl(self, position: Position, radius: int = 10) -> Dict:
|
||||
bonuses = {}
|
||||
for structure in self.structures.values():
|
||||
dist = ((structure.position.x - position.x) ** 2 + (structure.position.y - position.y) ** 2) ** 0.5
|
||||
if dist <= radius:
|
||||
for key, value in structure.bonuses.items():
|
||||
bonuses[key] = bonuses.get(key, 0) + value
|
||||
return bonuses
|
||||
|
||||
GameWorld._get_nearby_structure_bonuses = _get_nearby_structure_bonuses_impl
|
||||
|
||||
# Use plain dicts instead of Pydantic models
|
||||
|
||||
@@ -377,6 +468,7 @@ async def get_game_state():
|
||||
"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()},
|
||||
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
||||
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
|
||||
"game_active": game_world.is_game_active,
|
||||
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
||||
}
|
||||
@@ -403,10 +495,19 @@ async def move_player(player_id: str, request: Request):
|
||||
if distance > player.movement_capacity:
|
||||
raise HTTPException(status_code=400, detail="Movement exceeds capacity")
|
||||
|
||||
player.position.x += dx
|
||||
player.position.y += dy
|
||||
player.position.x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x))
|
||||
player.position.y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y))
|
||||
next_x = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.x + dx))
|
||||
next_y = max(-GRID_SIZE // 2, min(GRID_SIZE // 2, player.position.y + dy))
|
||||
|
||||
# Blocks are impassable.
|
||||
for s in game_world.structures.values():
|
||||
if s.structure_type != "block":
|
||||
continue
|
||||
bdist = ((s.position.x - next_x) ** 2 + (s.position.y - next_y) ** 2) ** 0.5
|
||||
if bdist < 0.6:
|
||||
raise HTTPException(status_code=400, detail="Blocked by structure")
|
||||
|
||||
player.position.x = next_x
|
||||
player.position.y = next_y
|
||||
player.action_points -= 1
|
||||
|
||||
return {"position": player.position.to_dict(), "action_points": player.action_points}
|
||||
@@ -429,6 +530,10 @@ async def player_action(player_id: str, request: Request):
|
||||
result = _handle_gather(player, body.get("target_id"))
|
||||
elif action_type == "build":
|
||||
result = _handle_build(player, body.get("tx"), body.get("ty"), body.get("structure_type"))
|
||||
elif action_type == "loot":
|
||||
result = _handle_loot(player)
|
||||
elif action_type == "equip":
|
||||
result = _handle_equip(player, body.get("slot_index"))
|
||||
else:
|
||||
return {"action": action_type, "success": False, "reason": "Unknown action"}
|
||||
|
||||
@@ -440,14 +545,17 @@ async def player_action(player_id: str, request: Request):
|
||||
|
||||
def _handle_attack(player: Player, target_id: str) -> Dict:
|
||||
"""Handle player attack"""
|
||||
attack_range = _player_effective_range(player)
|
||||
attack_power = _player_effective_attack(player)
|
||||
|
||||
if target_id in game_world.monsters:
|
||||
monster = game_world.monsters[target_id]
|
||||
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
||||
|
||||
if dist > 5:
|
||||
if dist > attack_range:
|
||||
return {"success": False, "reason": "Target too far"}
|
||||
|
||||
damage = max(1, player.attack + random.randint(-2, 2) - monster.defense)
|
||||
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
||||
monster.health -= damage
|
||||
|
||||
if monster.health <= 0:
|
||||
@@ -462,10 +570,10 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
||||
monster = game_world.boss
|
||||
dist = ((monster.position.x - player.position.x) ** 2 + (monster.position.y - player.position.y) ** 2) ** 0.5
|
||||
|
||||
if dist > 5:
|
||||
if dist > attack_range:
|
||||
return {"success": False, "reason": "Boss too far"}
|
||||
|
||||
damage = max(1, player.attack + random.randint(-2, 2) - monster.defense)
|
||||
damage = max(1, attack_power + random.randint(-2, 2) - monster.defense)
|
||||
monster.health -= damage
|
||||
old_progress = monster.boss_progress
|
||||
monster.boss_progress = (monster.max_health - monster.health) / monster.max_health * 100
|
||||
@@ -481,6 +589,19 @@ def _handle_attack(player: Player, target_id: str) -> Dict:
|
||||
|
||||
return {"success": True, "damage": damage, "boss_health_remaining": monster.health}
|
||||
|
||||
if target_id in game_world.structures:
|
||||
structure = game_world.structures[target_id]
|
||||
if structure.structure_type != "block":
|
||||
return {"success": False, "reason": "Only block structures are destructible"}
|
||||
dist = ((structure.position.x - player.position.x) ** 2 + (structure.position.y - player.position.y) ** 2) ** 0.5
|
||||
if dist > attack_range:
|
||||
return {"success": False, "reason": "Block too far"}
|
||||
structure.health -= 1
|
||||
if structure.health <= 0:
|
||||
del game_world.structures[target_id]
|
||||
return {"success": True, "damage": 1, "block_destroyed": True}
|
||||
return {"success": True, "damage": 1, "block_health_remaining": structure.health}
|
||||
|
||||
return {"success": False, "reason": "Target not found"}
|
||||
|
||||
def _handle_gather(player: Player, target_id: str) -> Dict:
|
||||
@@ -517,10 +638,15 @@ def _handle_gather(player: Player, target_id: str) -> Dict:
|
||||
|
||||
def _handle_build(player: Player, tx: float, ty: float, structure_type: str) -> Dict:
|
||||
"""Handle structure building"""
|
||||
if structure_type not in ["house", "farm", "guard_tower"]:
|
||||
if structure_type not in ["house", "farm", "guard_tower", "block"]:
|
||||
return {"success": False, "reason": "Invalid structure type"}
|
||||
|
||||
costs = {"house": {"wood": 20, "stone": 10}, "farm": {"wood": 15, "stone": 5}, "guard_tower": {"wood": 30, "stone": 20}}
|
||||
costs = {
|
||||
"house": {"wood": 20, "stone": 10},
|
||||
"farm": {"wood": 15, "stone": 5},
|
||||
"guard_tower": {"wood": 30, "stone": 20},
|
||||
"block": {"wood": BLOCK_BUILD_COST_WOOD, "stone": 0},
|
||||
}
|
||||
cost = costs[structure_type]
|
||||
|
||||
for material, amount in cost.items():
|
||||
@@ -531,9 +657,20 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) ->
|
||||
for material, amount in cost.items():
|
||||
player.inventory[material] -= amount
|
||||
|
||||
# Create structure
|
||||
# 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.
|
||||
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"}
|
||||
|
||||
if structure_type == "farm":
|
||||
bonuses["action_regen"] = 2
|
||||
elif structure_type == "guard_tower":
|
||||
@@ -543,15 +680,47 @@ def _handle_build(player: Player, tx: float, ty: float, structure_type: str) ->
|
||||
|
||||
game_world.structures[structure_id] = Structure(
|
||||
id=structure_id,
|
||||
position=Position(tx, ty, 0),
|
||||
position=Position(px, py, 0),
|
||||
structure_type=structure_type,
|
||||
owner_id=player.id,
|
||||
health=100,
|
||||
health=BLOCK_HEALTH if structure_type == "block" else 100,
|
||||
bonuses=bonuses,
|
||||
)
|
||||
|
||||
return {"success": True, "structure_id": structure_id, "inventory": player.inventory}
|
||||
|
||||
def _handle_loot(player: Player) -> Dict:
|
||||
nearby = []
|
||||
for chest in game_world.chests.values():
|
||||
if chest.opened:
|
||||
continue
|
||||
dist = ((chest.position.x - player.position.x) ** 2 + (chest.position.y - player.position.y) ** 2) ** 0.5
|
||||
if dist <= CHEST_LOOT_RANGE:
|
||||
nearby.append((dist, chest))
|
||||
|
||||
if not nearby:
|
||||
return {"success": False, "reason": "No chest nearby"}
|
||||
|
||||
nearby.sort(key=lambda x: x[0])
|
||||
chest = nearby[0][1]
|
||||
empty_slot = next((i for i, item in enumerate(player.weapon_slots) if item is None), None)
|
||||
if empty_slot is None:
|
||||
return {"success": False, "reason": "Weapon slots full"}
|
||||
|
||||
player.weapon_slots[empty_slot] = chest.weapon
|
||||
chest.opened = True
|
||||
return {"success": True, "weapon": chest.weapon, "slot_index": empty_slot, "weapon_slots": player.weapon_slots}
|
||||
|
||||
def _handle_equip(player: Player, slot_index) -> Dict:
|
||||
if slot_index is None:
|
||||
return {"success": False, "reason": "slot_index is required"}
|
||||
if not isinstance(slot_index, int) or slot_index < 0 or slot_index >= len(player.weapon_slots):
|
||||
return {"success": False, "reason": "Invalid slot index"}
|
||||
if player.weapon_slots[slot_index] is None:
|
||||
return {"success": False, "reason": "No weapon in that slot"}
|
||||
player.equipped_weapon_slot = slot_index
|
||||
return {"success": True, "equipped_weapon_slot": slot_index, "weapon": player.weapon_slots[slot_index]}
|
||||
|
||||
@app.websocket("/ws/{player_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
||||
"""WebSocket for real-time game updates"""
|
||||
@@ -560,6 +729,10 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
||||
return
|
||||
|
||||
player = game_world.players[player_id]
|
||||
# Capture the session at the time this WS connection is established so that
|
||||
# if the player re-logs (new session) while this socket is still open, the
|
||||
# finally block doesn't incorrectly mark the re-logged player as inactive.
|
||||
ws_session_id = player.session_id
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
@@ -579,6 +752,7 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
||||
"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()},
|
||||
"resources": {rid: r.to_dict() for rid, r in game_world.resources.items()},
|
||||
"chests": {cid: c.to_dict() for cid, c in game_world.chests.items()},
|
||||
"time_remaining": max(0, GAME_DURATION - game_world.get_elapsed_time()),
|
||||
"elapsed_time": game_world.get_elapsed_time(),
|
||||
}
|
||||
@@ -586,7 +760,9 @@ async def websocket_endpoint(websocket: WebSocket, player_id: str):
|
||||
except Exception as e:
|
||||
print(f"WebSocket error: {e}")
|
||||
finally:
|
||||
player.active = False
|
||||
# Only deactivate if this WS belongs to the current session (not superseded by a re-login).
|
||||
if player.session_id == ws_session_id:
|
||||
player.active = False
|
||||
|
||||
@app.post("/api/game/start")
|
||||
async def start_game():
|
||||
|
||||
Reference in New Issue
Block a user