Initial commit: Echoes of the Ashes - Telegram RPG Bot
This commit is contained in:
0
data/__init__.py
Normal file
0
data/__init__.py
Normal file
71
data/items.py
Normal file
71
data/items.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Definitions for all items in the game.
|
||||
# Now loaded from JSON file
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def load_items():
|
||||
"""Load items from JSON file"""
|
||||
json_path = Path(__file__).parent.parent / 'gamedata' / 'items.json'
|
||||
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return data['items']
|
||||
except FileNotFoundError:
|
||||
print(f"⚠️ Warning: {json_path} not found, using fallback items")
|
||||
return _get_fallback_items()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Error loading items from JSON: {e}")
|
||||
return _get_fallback_items()
|
||||
|
||||
def _get_fallback_items():
|
||||
"""Fallback items if JSON loading fails"""
|
||||
return {
|
||||
# Resources
|
||||
"scrap_metal": {"name": "Scrap Metal", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "⚙️"},
|
||||
"rusty_nails": {"name": "Rusty Nails", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "📌"},
|
||||
"wood_planks": {"name": "Wood Planks", "weight": 3.0, "volume": 2.0, "type": "resource", "emoji": "🪵"},
|
||||
"cloth_scraps": {"name": "Cloth Scraps", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
|
||||
"cloth": {"name": "Cloth", "weight": 0.1, "volume": 0.2, "type": "resource", "emoji": "🧵"},
|
||||
"plastic_bottles": {"name": "Plastic Bottles", "weight": 0.05, "volume": 0.3, "type": "resource", "emoji": "🍶"},
|
||||
"bone": {"name": "Bone", "weight": 0.3, "volume": 0.1, "type": "resource", "emoji": "🦴"},
|
||||
"raw_meat": {"name": "Raw Meat", "weight": 0.5, "volume": 0.2, "type": "resource", "emoji": "🥩"},
|
||||
"animal_hide": {"name": "Animal Hide", "weight": 0.4, "volume": 0.3, "type": "resource", "emoji": "🧤"},
|
||||
"mutant_tissue": {"name": "Mutant Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "🧬"},
|
||||
"infected_tissue": {"name": "Infected Tissue", "weight": 0.2, "volume": 0.1, "type": "resource", "emoji": "☣️"},
|
||||
|
||||
# Consumables - Food
|
||||
"stale_chocolate_bar": {"name": "Stale Chocolate Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 10, "emoji": "🍫"},
|
||||
"canned_beans": {"name": "Canned Beans", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 20, "stamina_restore": 5, "emoji": "🥫"},
|
||||
"canned_food": {"name": "Canned Food", "weight": 0.4, "volume": 0.2, "type": "consumable", "hp_restore": 25, "stamina_restore": 5, "emoji": "🥫"},
|
||||
"bottled_water": {"name": "Bottled Water", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
|
||||
"water_bottle": {"name": "Water Bottle", "weight": 0.5, "volume": 0.3, "type": "consumable", "stamina_restore": 10, "emoji": "💧"},
|
||||
"energy_bar": {"name": "Energy Bar", "weight": 0.1, "volume": 0.1, "type": "consumable", "stamina_restore": 15, "emoji": "🍫"},
|
||||
"mystery_pills": {"name": "Mystery Pills", "weight": 0.05, "volume": 0.05, "type": "consumable", "hp_restore": 30, "emoji": "💊"},
|
||||
|
||||
# Consumables - Medical
|
||||
"first_aid_kit": {"name": "First Aid Kit", "description": "A professional medical kit with bandages, antiseptic, and pain relievers.", "weight": 0.8, "volume": 0.5, "type": "consumable", "hp_restore": 50, "emoji": "🩹"},
|
||||
"bandage": {"name": "Bandage", "description": "Clean cloth bandages for treating minor wounds.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 15, "emoji": "🩹"},
|
||||
"medical_supplies": {"name": "Medical Supplies", "description": "Assorted medical supplies scavenged from a clinic.", "weight": 0.6, "volume": 0.4, "type": "consumable", "hp_restore": 40, "emoji": "⚕️"},
|
||||
"antibiotics": {"name": "Antibiotics", "description": "Pills that fight infections. Expired, but better than nothing.", "weight": 0.1, "volume": 0.1, "type": "consumable", "hp_restore": 20, "emoji": "💊"},
|
||||
|
||||
# Weapons & Tools
|
||||
"tire_iron": {"name": "Tire Iron", "description": "A heavy metal tool. Makes a decent improvised weapon.", "weight": 2.0, "volume": 1.0, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 7, "emoji": "🔧"},
|
||||
"baseball_bat": {"name": "Baseball Bat", "description": "Wooden bat with dents and bloodstains. Someone used this before you.", "weight": 1.0, "volume": 1.5, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 6, "emoji": "⚾"},
|
||||
"rusty_knife": {"name": "Rusty Knife", "description": "A dull, rusted blade. Better than your fists.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 2, "damage_max": 5, "emoji": "🔪"},
|
||||
"knife": {"name": "Knife", "description": "A sharp survival knife in decent condition.", "weight": 0.3, "volume": 0.2, "type": "weapon", "slot": "hand", "damage_min": 3, "damage_max": 6, "emoji": "🔪"},
|
||||
"rusty_pipe": {"name": "Rusty Pipe", "description": "Heavy metal pipe. Crude but effective.", "weight": 1.5, "volume": 0.8, "type": "weapon", "slot": "hand", "damage_min": 4, "damage_max": 8, "emoji": "🔩"},
|
||||
|
||||
# Equipment
|
||||
"tattered_rucksack": {"name": "Tattered Rucksack", "description": "An old backpack with torn straps. Still functional.", "weight": 1.0, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 10, "capacity_volume": 10, "emoji": "🎒"},
|
||||
"hiking_backpack": {"name": "Hiking Backpack", "description": "A quality backpack with multiple compartments.", "weight": 1.5, "volume": 0, "type": "equipment", "slot": "back", "capacity_weight": 20, "capacity_volume": 20, "emoji": "🎒"},
|
||||
"flashlight": {"name": "Flashlight", "description": "A battery-powered flashlight. Batteries low but working.", "weight": 0.3, "volume": 0.2, "type": "equipment", "slot": "tool", "emoji": "🔦"},
|
||||
|
||||
# Quest Items
|
||||
"old_photograph": {"name": "Old Photograph", "weight": 0.01, "volume": 0.01, "type": "quest", "emoji": "📷"},
|
||||
"key_ring": {"name": "Key Ring", "weight": 0.1, "volume": 0.05, "type": "quest", "emoji": "🔑"},
|
||||
}
|
||||
|
||||
# Load items from JSON on module import
|
||||
ITEMS = load_items()
|
||||
57
data/models.py
Normal file
57
data/models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
text: str
|
||||
items_reward: Dict[str, int] = field(default_factory=dict)
|
||||
damage_taken: int = 0
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
id: str
|
||||
label: str
|
||||
stamina_cost: int
|
||||
outcomes: Dict[str, Outcome] = field(default_factory=dict)
|
||||
def add_outcome(self, name: str, outcome: Outcome): self.outcomes[name] = outcome
|
||||
|
||||
@dataclass
|
||||
class Interactable:
|
||||
id: str
|
||||
name: str
|
||||
actions: Dict[str, Action] = field(default_factory=dict)
|
||||
image_path: Optional[str] = None
|
||||
def add_action(self, action: Action): self.actions[action.id] = action
|
||||
def get_action(self, action_id: str) -> Optional[Action]: return self.actions.get(action_id)
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
exits: Dict[str, str] = field(default_factory=dict)
|
||||
interactables: Dict[str, Interactable] = field(default_factory=dict) # Key is now the INSTANCE_ID
|
||||
image_path: Optional[str] = None
|
||||
x: float = 0.0 # X coordinate for map positioning
|
||||
y: float = 0.0 # Y coordinate for map positioning
|
||||
|
||||
def add_exit(self, direction: str, destination_id: str):
|
||||
self.exits[direction] = destination_id
|
||||
|
||||
def add_interactable(self, instance_id: str, interactable_template: Interactable):
|
||||
"""Adds an instance of an interactable template to the location."""
|
||||
self.interactables[instance_id] = interactable_template
|
||||
|
||||
def get_interactable(self, instance_id: str) -> Optional[Interactable]:
|
||||
return self.interactables.get(instance_id)
|
||||
|
||||
class World:
|
||||
_instance = None
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(World, cls).__new__(cls)
|
||||
cls._instance.locations = {}
|
||||
return cls._instance
|
||||
|
||||
def add_location(self, location: Location): self.locations[location.id] = location
|
||||
def get_location(self, location_id: str) -> Optional[Location]: return self.locations.get(location_id)
|
||||
236
data/npcs.py
Normal file
236
data/npcs.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
NPC definitions for combat encounters - NOW LOADED FROM JSON
|
||||
Each NPC has stats, loot tables, and combat behavior.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LootItem:
|
||||
"""Item that can be dropped by NPCs"""
|
||||
item_id: str
|
||||
quantity_min: int
|
||||
quantity_max: int
|
||||
drop_chance: float # 0.0 to 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorpseLoot:
|
||||
"""Item that can be scavenged from a corpse"""
|
||||
item_id: str
|
||||
quantity_min: int
|
||||
quantity_max: int
|
||||
required_tool: Optional[str] = None # item_id of required tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusEffect:
|
||||
"""Status effect data"""
|
||||
name: str
|
||||
duration_turns: int
|
||||
damage_per_turn: int = 0 # For bleeding
|
||||
stun: bool = False # Prevents action
|
||||
|
||||
|
||||
@dataclass
|
||||
class NPCDefinition:
|
||||
"""Complete NPC definition"""
|
||||
npc_id: str
|
||||
name: str
|
||||
description: str
|
||||
emoji: str
|
||||
|
||||
# Combat stats
|
||||
hp_min: int
|
||||
hp_max: int
|
||||
damage_min: int
|
||||
damage_max: int
|
||||
defense: int # Reduces incoming damage
|
||||
|
||||
# Rewards
|
||||
xp_reward: int
|
||||
loot_table: List[LootItem]
|
||||
corpse_loot: List[CorpseLoot]
|
||||
|
||||
# Behavior
|
||||
flee_chance: float # NPC's chance to flee if low HP
|
||||
status_inflict_chance: float # Chance to inflict status on player
|
||||
|
||||
# Visuals
|
||||
image_url: Optional[str] = None
|
||||
death_message: str = "The enemy falls defeated."
|
||||
|
||||
|
||||
def load_npcs_from_json():
|
||||
"""Load NPCs, danger levels, and spawn tables from JSON"""
|
||||
json_path = Path(__file__).parent.parent / 'gamedata' / 'npcs.json'
|
||||
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert JSON to NPCDefinition objects
|
||||
npcs = {}
|
||||
for npc_id, npc_data in data['npcs'].items():
|
||||
# Convert loot tables
|
||||
loot_table = [
|
||||
LootItem(**loot) for loot in npc_data.get('loot_table', [])
|
||||
]
|
||||
corpse_loot = [
|
||||
CorpseLoot(**loot) for loot in npc_data.get('corpse_loot', [])
|
||||
]
|
||||
|
||||
# Create NPC definition
|
||||
npcs[npc_id] = NPCDefinition(
|
||||
npc_id=npc_data['npc_id'],
|
||||
name=npc_data['name'],
|
||||
description=npc_data['description'],
|
||||
emoji=npc_data['emoji'],
|
||||
hp_min=npc_data['hp_min'],
|
||||
hp_max=npc_data['hp_max'],
|
||||
damage_min=npc_data['damage_min'],
|
||||
damage_max=npc_data['damage_max'],
|
||||
defense=npc_data['defense'],
|
||||
xp_reward=npc_data['xp_reward'],
|
||||
loot_table=loot_table,
|
||||
corpse_loot=corpse_loot,
|
||||
flee_chance=npc_data['flee_chance'],
|
||||
status_inflict_chance=npc_data['status_inflict_chance'],
|
||||
image_url=npc_data.get('image_url'),
|
||||
death_message=npc_data.get('death_message', "The enemy falls defeated.")
|
||||
)
|
||||
|
||||
# Load danger levels - convert to tuple format (danger_level, encounter_rate, wandering_chance)
|
||||
danger_levels = {}
|
||||
for loc_id, danger_data in data['danger_levels'].items():
|
||||
danger_levels[loc_id] = (
|
||||
danger_data['danger_level'],
|
||||
danger_data['encounter_rate'],
|
||||
danger_data['wandering_chance']
|
||||
)
|
||||
|
||||
# Load spawn tables - convert to list of tuples format
|
||||
spawn_tables = {}
|
||||
for loc_id, spawns in data['spawn_tables'].items():
|
||||
spawn_tables[loc_id] = [
|
||||
(spawn['npc_id'], spawn['weight']) for spawn in spawns
|
||||
]
|
||||
|
||||
print(f"✅ Loaded {len(npcs)} NPCs, {len(danger_levels)} danger configs, {len(spawn_tables)} spawn tables from JSON")
|
||||
return npcs, danger_levels, spawn_tables
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"⚠️ Warning: {json_path} not found, using fallback NPCs")
|
||||
return _get_fallback_npcs()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Error loading NPCs from JSON: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return _get_fallback_npcs()
|
||||
|
||||
|
||||
def _get_fallback_npcs():
|
||||
"""Fallback NPCs if JSON loading fails"""
|
||||
npcs = {
|
||||
"feral_dog": NPCDefinition(
|
||||
npc_id="feral_dog",
|
||||
name="Feral Dog",
|
||||
description="A wild, mangy dog with desperate hunger in its eyes.",
|
||||
emoji="🐕",
|
||||
hp_min=15,
|
||||
hp_max=25,
|
||||
damage_min=3,
|
||||
damage_max=7,
|
||||
defense=0,
|
||||
xp_reward=10,
|
||||
flee_chance=0.3,
|
||||
status_inflict_chance=0.15,
|
||||
loot_table=[
|
||||
LootItem("raw_meat", 1, 2, 0.6),
|
||||
LootItem("bone", 1, 1, 0.4),
|
||||
LootItem("animal_hide", 1, 1, 0.3)
|
||||
],
|
||||
corpse_loot=[
|
||||
CorpseLoot("raw_meat", 1, 2),
|
||||
CorpseLoot("bone", 1, 1),
|
||||
CorpseLoot("animal_hide", 1, 1, required_tool="knife")
|
||||
],
|
||||
image_url=None,
|
||||
death_message="The feral dog whimpers and collapses."
|
||||
)
|
||||
}
|
||||
|
||||
danger_levels = {
|
||||
"start_point": (0, 0.0, 0.0),
|
||||
}
|
||||
|
||||
spawn_tables = {
|
||||
"start_point": [],
|
||||
}
|
||||
|
||||
return npcs, danger_levels, spawn_tables
|
||||
|
||||
|
||||
# Load on module import
|
||||
NPCS, LOCATION_DANGER, LOCATION_SPAWNS = load_npcs_from_json()
|
||||
|
||||
# Status effects that can be applied in combat
|
||||
STATUS_EFFECTS = {
|
||||
"bleeding": StatusEffect(
|
||||
name="Bleeding",
|
||||
duration_turns=3,
|
||||
damage_per_turn=2,
|
||||
stun=False
|
||||
),
|
||||
"stunned": StatusEffect(
|
||||
name="Stunned",
|
||||
duration_turns=1,
|
||||
damage_per_turn=0,
|
||||
stun=True
|
||||
),
|
||||
"infected": StatusEffect(
|
||||
name="Infected",
|
||||
duration_turns=5,
|
||||
damage_per_turn=1,
|
||||
stun=False
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Helper functions
|
||||
def get_danger_level(location_id: str) -> int:
|
||||
"""Get danger level for a location (0-4)"""
|
||||
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[0]
|
||||
|
||||
|
||||
def get_location_encounter_rate(location_id: str) -> float:
|
||||
"""Get base encounter rate for a location"""
|
||||
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[1]
|
||||
|
||||
|
||||
def get_wandering_enemy_chance(location_id: str) -> float:
|
||||
"""Get chance for wandering enemy to spawn"""
|
||||
return LOCATION_DANGER.get(location_id, (0, 0.0, 0.0))[2]
|
||||
|
||||
|
||||
def get_random_npc_for_location(location_id: str) -> str:
|
||||
"""
|
||||
Get a random NPC ID for the given location based on spawn weights.
|
||||
Returns None if no NPCs can spawn at this location.
|
||||
"""
|
||||
import random
|
||||
|
||||
spawn_table = LOCATION_SPAWNS.get(location_id, [])
|
||||
if not spawn_table:
|
||||
return None
|
||||
|
||||
# Extract NPCs and weights
|
||||
npcs = [npc_id for npc_id, weight in spawn_table]
|
||||
weights = [weight for npc_id, weight in spawn_table]
|
||||
|
||||
# Use random.choices with weights
|
||||
return random.choices(npcs, weights=weights, k=1)[0] if npcs else None
|
||||
31
data/travel_helpers.py
Normal file
31
data/travel_helpers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Shared travel/stamina calculation helpers used by both game logic and map visualization
|
||||
"""
|
||||
import math
|
||||
|
||||
def calculate_base_stamina_cost(from_location, to_location) -> int:
|
||||
"""
|
||||
Calculate base stamina cost for traveling between two locations.
|
||||
Based purely on Euclidean distance.
|
||||
|
||||
This is the base cost used by:
|
||||
- Map visualization (to show connection costs)
|
||||
- Game logic (as starting point before player modifiers)
|
||||
|
||||
Args:
|
||||
from_location: Location object with x, y coordinates
|
||||
to_location: Location object with x, y coordinates
|
||||
|
||||
Returns:
|
||||
int: Base stamina cost (minimum 1)
|
||||
"""
|
||||
# Calculate Euclidean distance
|
||||
dx = to_location.x - from_location.x
|
||||
dy = to_location.y - from_location.y
|
||||
distance = math.sqrt(dx**2 + dy**2)
|
||||
|
||||
# Base cost: 3 stamina per distance unit (rounded)
|
||||
# Minimum cost is 1 stamina
|
||||
base_cost = max(1, int(distance * 3 + 0.5))
|
||||
|
||||
return base_cost
|
||||
313
data/world_loader.py
Normal file
313
data/world_loader.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
World loader - NOW LOADS FROM JSON
|
||||
Creates and connects all the game world objects from gamedata/locations.json
|
||||
Uses template-based interactables from gamedata/interactables.json
|
||||
"""
|
||||
|
||||
from .models import World, Location, Interactable, Action, Outcome
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_interactable_templates():
|
||||
"""Load interactable templates from interactables.json."""
|
||||
templates = {}
|
||||
json_path = Path(__file__).parent.parent / 'gamedata' / 'interactables.json'
|
||||
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
for template_id, template_data in data.get('interactables', {}).items():
|
||||
templates[template_id] = template_data
|
||||
|
||||
print(f"📦 Loaded {len(templates)} interactable templates")
|
||||
except FileNotFoundError:
|
||||
print("⚠️ interactables.json not found, using inline data only")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading interactables.json: {e}")
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
def create_interactable_from_template(template_id, template_data, instance_data):
|
||||
"""Create an Interactable object from template and instance data."""
|
||||
interactable = Interactable(
|
||||
id=template_id,
|
||||
name=template_data.get('name', 'Unknown'),
|
||||
image_path=template_data.get('image_path', '')
|
||||
)
|
||||
|
||||
# Get actions from template
|
||||
template_actions = template_data.get('actions', {})
|
||||
|
||||
# Get outcomes from instance (template-based format)
|
||||
instance_outcomes = instance_data.get('outcomes', {})
|
||||
|
||||
# Build actions by merging template actions with instance outcomes
|
||||
for action_id, action_template in template_actions.items():
|
||||
action = Action(
|
||||
id=action_template['id'],
|
||||
label=action_template['label'],
|
||||
stamina_cost=action_template.get('stamina_cost', 2)
|
||||
)
|
||||
|
||||
# Get the instance-specific outcome data for this action
|
||||
if action_id in instance_outcomes:
|
||||
outcome_data = instance_outcomes[action_id]
|
||||
|
||||
# Build outcomes from the instance data
|
||||
text_dict = outcome_data.get('text', {})
|
||||
rewards = outcome_data.get('rewards', {})
|
||||
|
||||
# Add success outcome
|
||||
if text_dict.get('success'):
|
||||
items_reward = {}
|
||||
if 'items' in rewards:
|
||||
for item in rewards['items']:
|
||||
items_reward[item['item_id']] = item.get('quantity', 1)
|
||||
|
||||
outcome = Outcome(
|
||||
text=text_dict['success'],
|
||||
items_reward=items_reward,
|
||||
damage_taken=rewards.get('damage', 0)
|
||||
)
|
||||
action.add_outcome('success', outcome)
|
||||
|
||||
# Add failure outcome
|
||||
if text_dict.get('failure'):
|
||||
outcome = Outcome(
|
||||
text=text_dict['failure'],
|
||||
items_reward={},
|
||||
damage_taken=0
|
||||
)
|
||||
action.add_outcome('failure', outcome)
|
||||
|
||||
# Add critical failure outcome
|
||||
if text_dict.get('crit_failure'):
|
||||
outcome = Outcome(
|
||||
text=text_dict['crit_failure'],
|
||||
items_reward={},
|
||||
damage_taken=rewards.get('crit_damage', 0)
|
||||
)
|
||||
action.add_outcome('critical_failure', outcome)
|
||||
|
||||
interactable.add_action(action)
|
||||
|
||||
return interactable
|
||||
|
||||
|
||||
def load_world() -> World:
|
||||
"""Creates and connects all the game world objects from JSON."""
|
||||
world = World()
|
||||
|
||||
# Load interactable templates first
|
||||
templates = load_interactable_templates()
|
||||
|
||||
json_path = Path(__file__).parent.parent / 'gamedata' / 'locations.json'
|
||||
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
print(f"📍 Loading {len(data['locations'])} locations from JSON...")
|
||||
|
||||
# First pass: Create all locations
|
||||
for loc_data in data['locations']:
|
||||
location = Location(
|
||||
id=loc_data['id'],
|
||||
name=loc_data['name'],
|
||||
description=loc_data['description'],
|
||||
image_path=loc_data['image_path'],
|
||||
x=loc_data.get('x', 0.0),
|
||||
y=loc_data.get('y', 0.0)
|
||||
)
|
||||
|
||||
# Add interactables using template-based format
|
||||
for instance_id, instance_data in loc_data.get('interactables', {}).items():
|
||||
template_id = instance_data.get('template_id')
|
||||
|
||||
if not template_id:
|
||||
print(f"⚠️ Skipping interactable {instance_id} - no template_id")
|
||||
continue
|
||||
|
||||
# Get template data
|
||||
template_data = templates.get(template_id)
|
||||
|
||||
if not template_data:
|
||||
print(f"⚠️ Template '{template_id}' not found for {instance_id}")
|
||||
continue
|
||||
|
||||
# Create interactable from template + instance data
|
||||
interactable = create_interactable_from_template(
|
||||
template_id,
|
||||
template_data,
|
||||
instance_data
|
||||
)
|
||||
|
||||
location.add_interactable(instance_id, interactable)
|
||||
|
||||
world.add_location(location)
|
||||
|
||||
# Second pass: Add connections
|
||||
print(f"🔗 Adding {len(data['connections'])} connections...")
|
||||
for conn in data['connections']:
|
||||
from_loc = world.get_location(conn['from'])
|
||||
if from_loc:
|
||||
from_loc.add_exit(conn['direction'], conn['to'])
|
||||
|
||||
print(f"✅ World loaded successfully!")
|
||||
return world
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"⚠️ Warning: {json_path} not found, loading fallback world")
|
||||
return _load_fallback_world()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading world from JSON: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return _load_fallback_world()
|
||||
|
||||
|
||||
def _load_fallback_world() -> World:
|
||||
"""Fallback world with minimal locations if JSON loading fails"""
|
||||
world = World()
|
||||
|
||||
# Create a simple fallback location
|
||||
start = Location(
|
||||
id="start_point",
|
||||
name="🌆 Ruined Downtown Core",
|
||||
description="The wind howls through skeletal skyscrapers. Debris litters the cracked asphalt.",
|
||||
image_path="images/locations/downtown.png",
|
||||
x=0.0,
|
||||
y=0.0
|
||||
)
|
||||
|
||||
# Add a simple interactable
|
||||
rubble = Interactable(
|
||||
id="rubble",
|
||||
name="Pile of Rubble",
|
||||
image_path="images/interactables/rubble.png"
|
||||
)
|
||||
search_action = Action(id="search", label="🔎 Search Rubble", stamina_cost=2)
|
||||
search_action.add_outcome("success", Outcome(
|
||||
text="You find some scrap metal.",
|
||||
items_reward={"scrap_metal": 2}
|
||||
))
|
||||
search_action.add_outcome("failure", Outcome(
|
||||
text="Nothing useful here."
|
||||
))
|
||||
rubble.add_action(search_action)
|
||||
start.add_interactable("start_rubble", rubble)
|
||||
|
||||
world.add_location(start)
|
||||
|
||||
return world
|
||||
|
||||
|
||||
def export_map_data() -> dict:
|
||||
"""
|
||||
Export map data for external visualization.
|
||||
Returns a dictionary with locations, connections, interactables, and enemy spawns.
|
||||
Can be saved as JSON for use in web-based or other visualizers.
|
||||
"""
|
||||
from data.npcs import LOCATION_SPAWNS, NPCS, LOCATION_DANGER, get_danger_level, get_location_encounter_rate, get_wandering_enemy_chance
|
||||
|
||||
map_data = {
|
||||
"locations": [],
|
||||
"connections": [],
|
||||
"interactables": [],
|
||||
"spawn_tables": {}
|
||||
}
|
||||
|
||||
for location in game_world.locations.values():
|
||||
# Get danger information
|
||||
danger_level = get_danger_level(location.id)
|
||||
encounter_rate = get_location_encounter_rate(location.id)
|
||||
wandering_chance = get_wandering_enemy_chance(location.id)
|
||||
|
||||
# Add location data with image and danger info
|
||||
map_data["locations"].append({
|
||||
"id": location.id,
|
||||
"name": location.name,
|
||||
"description": location.description,
|
||||
"x": location.x,
|
||||
"y": location.y,
|
||||
"image_path": location.image_path,
|
||||
"interactable_count": len(location.interactables),
|
||||
"danger_level": danger_level,
|
||||
"encounter_rate": round(encounter_rate * 100, 1), # As percentage
|
||||
"wandering_chance": round(wandering_chance * 100, 1) # As percentage
|
||||
})
|
||||
|
||||
# Add interactable data with images and loot chances
|
||||
for instance_id, interactable in location.interactables.items():
|
||||
interactable_data = {
|
||||
"instance_id": instance_id,
|
||||
"location_id": location.id,
|
||||
"id": interactable.id,
|
||||
"name": interactable.name,
|
||||
"image_path": interactable.image_path,
|
||||
"actions": []
|
||||
}
|
||||
|
||||
# Process each action and its outcomes
|
||||
for action in interactable.actions.values():
|
||||
action_data = {
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
"stamina_cost": action.stamina_cost,
|
||||
"outcomes": []
|
||||
}
|
||||
|
||||
# Add outcome information (for loot chances)
|
||||
for outcome_name, outcome in action.outcomes.items():
|
||||
outcome_data = {
|
||||
"type": outcome_name,
|
||||
"text": outcome.text,
|
||||
"items": outcome.items_reward,
|
||||
"damage": outcome.damage_taken
|
||||
}
|
||||
action_data["outcomes"].append(outcome_data)
|
||||
|
||||
interactable_data["actions"].append(action_data)
|
||||
|
||||
map_data["interactables"].append(interactable_data)
|
||||
|
||||
# Add connections
|
||||
for direction, destination in location.exits.items():
|
||||
# Calculate stamina cost based on distance between locations
|
||||
dest_loc = game_world.get_location(destination)
|
||||
if dest_loc:
|
||||
from .travel_helpers import calculate_base_stamina_cost
|
||||
stamina_cost = calculate_base_stamina_cost(location, dest_loc)
|
||||
else:
|
||||
stamina_cost = 5 # Fallback
|
||||
|
||||
map_data["connections"].append({
|
||||
"from": location.id,
|
||||
"to": destination,
|
||||
"direction": direction,
|
||||
"stamina_cost": stamina_cost
|
||||
})
|
||||
|
||||
# Add spawn table for this location
|
||||
if location.id in LOCATION_SPAWNS:
|
||||
spawn_data = []
|
||||
for npc_id, weight in LOCATION_SPAWNS[location.id]:
|
||||
if npc_id in NPCS:
|
||||
npc = NPCS[npc_id]
|
||||
spawn_data.append({
|
||||
"npc_id": npc_id,
|
||||
"name": npc.name,
|
||||
"emoji": npc.emoji,
|
||||
"weight": weight,
|
||||
"level_range": f"{npc.hp_min}-{npc.hp_max} HP"
|
||||
})
|
||||
map_data["spawn_tables"][location.id] = spawn_data
|
||||
|
||||
return map_data
|
||||
|
||||
|
||||
# Create singleton world instance on module import
|
||||
game_world = load_world()
|
||||
Reference in New Issue
Block a user