Expand city to 34 locations with new buildings, pan controls, and 2.5D web UI
Browse files- Add school, office tower, factory, hospital, 3 apartment blocks, bakery,
cinema, church, town square, sports field, east/west avenues
- New building sprites: factory with chimney smoke, hospital with red cross,
church with steeple, cinema with marquee lights, sports field with track
- Pan system: drag canvas or use sliders to scroll larger world (1.6x1.4)
- Sidebar narrowed 380->260px, agent clustering improved for large crowds
- Occupation-aware work locations (lawyer->tower, mechanic->factory, etc.)
- Routines use new locations for lunch, leisure, and evening entertainment
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- config/city.yaml +113 -15
- src/soci/agents/generator.py +32 -36
- src/soci/agents/routine.py +16 -7
- web/index.html +463 -72
config/city.yaml
CHANGED
|
@@ -2,7 +2,7 @@ name: Soci City
|
|
| 2 |
|
| 3 |
locations:
|
| 4 |
# ===================
|
| 5 |
-
# RESIDENTIAL — 10 individual houses
|
| 6 |
# ===================
|
| 7 |
- id: house_elena
|
| 8 |
name: "Elena & Lila's Apartment"
|
|
@@ -37,14 +37,14 @@ locations:
|
|
| 37 |
zone: residential
|
| 38 |
description: A small studio apartment crammed with musical instruments, vinyl records, and empty coffee cups.
|
| 39 |
capacity: 2
|
| 40 |
-
connected_to: [
|
| 41 |
|
| 42 |
- id: house_priya
|
| 43 |
name: "Priya & Nina's Flat"
|
| 44 |
zone: residential
|
| 45 |
description: A modern flat kept immaculately clean. Family photos on one side, property listings on the other.
|
| 46 |
capacity: 3
|
| 47 |
-
connected_to: [
|
| 48 |
|
| 49 |
- id: house_james
|
| 50 |
name: "James & Theo's House"
|
|
@@ -74,6 +74,27 @@ locations:
|
|
| 74 |
capacity: 4
|
| 75 |
connected_to: [street_south, bar, library]
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# ===================
|
| 78 |
# COMMERCIAL
|
| 79 |
# ===================
|
|
@@ -82,28 +103,42 @@ locations:
|
|
| 82 |
zone: commercial
|
| 83 |
description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
|
| 84 |
capacity: 15
|
| 85 |
-
connected_to: [house_elena, house_helen, house_diana, house_kai, street_north, office]
|
| 86 |
|
| 87 |
- id: grocery
|
| 88 |
name: Green Basket Market
|
| 89 |
zone: commercial
|
| 90 |
description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
|
| 91 |
capacity: 12
|
| 92 |
-
connected_to: [house_diana, house_rosa, street_north, street_south]
|
| 93 |
|
| 94 |
- id: bar
|
| 95 |
name: The Rusty Anchor
|
| 96 |
zone: commercial
|
| 97 |
description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
|
| 98 |
capacity: 15
|
| 99 |
-
connected_to: [house_james, house_frank, restaurant, street_south]
|
| 100 |
|
| 101 |
- id: restaurant
|
| 102 |
name: Mama Rosa's Kitchen
|
| 103 |
zone: commercial
|
| 104 |
description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
|
| 105 |
capacity: 12
|
| 106 |
-
connected_to: [house_rosa, bar, office, street_south]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
# ===================
|
| 109 |
# WORK
|
|
@@ -112,8 +147,36 @@ locations:
|
|
| 112 |
name: The Hive Coworking
|
| 113 |
zone: work
|
| 114 |
description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
capacity: 20
|
| 116 |
-
connected_to: [
|
| 117 |
|
| 118 |
# ===================
|
| 119 |
# PUBLIC
|
|
@@ -123,14 +186,14 @@ locations:
|
|
| 123 |
zone: public
|
| 124 |
description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
|
| 125 |
capacity: 30
|
| 126 |
-
connected_to: [house_elena, house_marcus, house_kai, house_priya, gym, library, street_north]
|
| 127 |
|
| 128 |
- id: gym
|
| 129 |
name: Iron & Grit Gym
|
| 130 |
zone: public
|
| 131 |
description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back.
|
| 132 |
-
capacity:
|
| 133 |
-
connected_to: [house_marcus, house_james, house_yuki, park, street_south]
|
| 134 |
|
| 135 |
- id: library
|
| 136 |
name: Soci Public Library
|
|
@@ -139,19 +202,54 @@ locations:
|
|
| 139 |
capacity: 15
|
| 140 |
connected_to: [house_helen, house_yuki, house_frank, park, street_south]
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
# ===================
|
| 143 |
-
# STREETS — connectors
|
| 144 |
# ===================
|
| 145 |
- id: street_north
|
| 146 |
name: North Main Street
|
| 147 |
zone: public
|
| 148 |
description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
|
| 149 |
-
capacity:
|
| 150 |
-
connected_to: [house_elena, house_marcus, house_helen, house_diana,
|
| 151 |
|
| 152 |
- id: street_south
|
| 153 |
name: South Main Street
|
| 154 |
zone: public
|
| 155 |
description: The southern stretch of main street. More residential, with the bar and gym nearby.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
capacity: 40
|
| 157 |
-
connected_to: [
|
|
|
|
| 2 |
|
| 3 |
locations:
|
| 4 |
# ===================
|
| 5 |
+
# RESIDENTIAL — 10 individual houses + 3 apartment blocks
|
| 6 |
# ===================
|
| 7 |
- id: house_elena
|
| 8 |
name: "Elena & Lila's Apartment"
|
|
|
|
| 37 |
zone: residential
|
| 38 |
description: A small studio apartment crammed with musical instruments, vinyl records, and empty coffee cups.
|
| 39 |
capacity: 2
|
| 40 |
+
connected_to: [street_west, cafe, park]
|
| 41 |
|
| 42 |
- id: house_priya
|
| 43 |
name: "Priya & Nina's Flat"
|
| 44 |
zone: residential
|
| 45 |
description: A modern flat kept immaculately clean. Family photos on one side, property listings on the other.
|
| 46 |
capacity: 3
|
| 47 |
+
connected_to: [street_east, office, park]
|
| 48 |
|
| 49 |
- id: house_james
|
| 50 |
name: "James & Theo's House"
|
|
|
|
| 74 |
capacity: 4
|
| 75 |
connected_to: [street_south, bar, library]
|
| 76 |
|
| 77 |
+
- id: apartment_block_1
|
| 78 |
+
name: Northside Apartments
|
| 79 |
+
zone: residential
|
| 80 |
+
description: A modern four-story apartment building with a rooftop terrace and shared laundry room.
|
| 81 |
+
capacity: 8
|
| 82 |
+
connected_to: [street_north, cafe, park, church]
|
| 83 |
+
|
| 84 |
+
- id: apartment_block_2
|
| 85 |
+
name: Southside Apartments
|
| 86 |
+
zone: residential
|
| 87 |
+
description: A brick apartment building with a courtyard garden and friendly neighbors who watch out for each other.
|
| 88 |
+
capacity: 8
|
| 89 |
+
connected_to: [street_south, bar, gym, cinema]
|
| 90 |
+
|
| 91 |
+
- id: apartment_block_3
|
| 92 |
+
name: Central Apartments
|
| 93 |
+
zone: residential
|
| 94 |
+
description: A newly renovated building right off the town square. Convenient but noisy on weekends.
|
| 95 |
+
capacity: 8
|
| 96 |
+
connected_to: [town_square, grocery, street_north, street_south]
|
| 97 |
+
|
| 98 |
# ===================
|
| 99 |
# COMMERCIAL
|
| 100 |
# ===================
|
|
|
|
| 103 |
zone: commercial
|
| 104 |
description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
|
| 105 |
capacity: 15
|
| 106 |
+
connected_to: [house_elena, house_helen, house_diana, house_kai, street_north, office, bakery]
|
| 107 |
|
| 108 |
- id: grocery
|
| 109 |
name: Green Basket Market
|
| 110 |
zone: commercial
|
| 111 |
description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
|
| 112 |
capacity: 12
|
| 113 |
+
connected_to: [house_diana, house_rosa, apartment_block_3, street_north, street_south]
|
| 114 |
|
| 115 |
- id: bar
|
| 116 |
name: The Rusty Anchor
|
| 117 |
zone: commercial
|
| 118 |
description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
|
| 119 |
capacity: 15
|
| 120 |
+
connected_to: [house_james, house_frank, apartment_block_2, restaurant, street_south, cinema]
|
| 121 |
|
| 122 |
- id: restaurant
|
| 123 |
name: Mama Rosa's Kitchen
|
| 124 |
zone: commercial
|
| 125 |
description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
|
| 126 |
capacity: 12
|
| 127 |
+
connected_to: [house_rosa, bar, office, street_south, town_square]
|
| 128 |
+
|
| 129 |
+
- id: bakery
|
| 130 |
+
name: Golden Crust Bakery
|
| 131 |
+
zone: commercial
|
| 132 |
+
description: A charming bakery with a glass display of fresh pastries. The smell of bread draws people in from the street.
|
| 133 |
+
capacity: 10
|
| 134 |
+
connected_to: [street_north, cafe, apartment_block_1]
|
| 135 |
+
|
| 136 |
+
- id: cinema
|
| 137 |
+
name: Starlight Cinema
|
| 138 |
+
zone: commercial
|
| 139 |
+
description: A vintage two-screen cinema with red velvet seats and a popcorn machine that never stops.
|
| 140 |
+
capacity: 15
|
| 141 |
+
connected_to: [street_south, bar, town_square, apartment_block_2]
|
| 142 |
|
| 143 |
# ===================
|
| 144 |
# WORK
|
|
|
|
| 147 |
name: The Hive Coworking
|
| 148 |
zone: work
|
| 149 |
description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
|
| 150 |
+
capacity: 25
|
| 151 |
+
connected_to: [house_priya, cafe, restaurant, street_north, town_square]
|
| 152 |
+
|
| 153 |
+
- id: office_tower
|
| 154 |
+
name: Pinnacle Tower
|
| 155 |
+
zone: work
|
| 156 |
+
description: A sleek glass office building housing law firms, tech startups, and financial advisors across six floors.
|
| 157 |
+
capacity: 30
|
| 158 |
+
connected_to: [street_north, street_east, cafe, town_square]
|
| 159 |
+
|
| 160 |
+
- id: factory
|
| 161 |
+
name: Ironworks Factory
|
| 162 |
+
zone: work
|
| 163 |
+
description: A large industrial building with a loading dock, welding bays, and the constant hum of machinery.
|
| 164 |
+
capacity: 25
|
| 165 |
+
connected_to: [street_south, street_east, sports_field]
|
| 166 |
+
|
| 167 |
+
- id: school
|
| 168 |
+
name: Soci Elementary & High
|
| 169 |
+
zone: work
|
| 170 |
+
description: A two-story school building with a playground, science lab, and the sound of a bell every hour.
|
| 171 |
+
capacity: 25
|
| 172 |
+
connected_to: [street_north, street_west, park, church]
|
| 173 |
+
|
| 174 |
+
- id: hospital
|
| 175 |
+
name: Soci City Hospital
|
| 176 |
+
zone: work
|
| 177 |
+
description: A small community hospital with an ER, a few wards, and a cafeteria that serves surprisingly good soup.
|
| 178 |
capacity: 20
|
| 179 |
+
connected_to: [street_north, street_east, street_south]
|
| 180 |
|
| 181 |
# ===================
|
| 182 |
# PUBLIC
|
|
|
|
| 186 |
zone: public
|
| 187 |
description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
|
| 188 |
capacity: 30
|
| 189 |
+
connected_to: [house_elena, house_marcus, house_kai, house_priya, gym, library, street_north, church, school, sports_field]
|
| 190 |
|
| 191 |
- id: gym
|
| 192 |
name: Iron & Grit Gym
|
| 193 |
zone: public
|
| 194 |
description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back.
|
| 195 |
+
capacity: 15
|
| 196 |
+
connected_to: [house_marcus, house_james, house_yuki, apartment_block_2, park, street_south, sports_field]
|
| 197 |
|
| 198 |
- id: library
|
| 199 |
name: Soci Public Library
|
|
|
|
| 202 |
capacity: 15
|
| 203 |
connected_to: [house_helen, house_yuki, house_frank, park, street_south]
|
| 204 |
|
| 205 |
+
- id: church
|
| 206 |
+
name: "St. Mary's Church"
|
| 207 |
+
zone: public
|
| 208 |
+
description: A stone church with stained glass windows, a quiet garden, and community gatherings every Sunday.
|
| 209 |
+
capacity: 15
|
| 210 |
+
connected_to: [street_north, street_west, park, apartment_block_1, school]
|
| 211 |
+
|
| 212 |
+
- id: town_square
|
| 213 |
+
name: Town Square
|
| 214 |
+
zone: public
|
| 215 |
+
description: The heart of Soci City. A cobblestone plaza with a fountain, benches, a notice board, and a weekend farmers market.
|
| 216 |
+
capacity: 40
|
| 217 |
+
connected_to: [street_north, street_south, office, office_tower, restaurant, cinema, apartment_block_3]
|
| 218 |
+
|
| 219 |
+
- id: sports_field
|
| 220 |
+
name: Community Sports Field
|
| 221 |
+
zone: public
|
| 222 |
+
description: A large grassy field with soccer goals, a running track, and bleachers. Busy on evenings and weekends.
|
| 223 |
+
capacity: 30
|
| 224 |
+
connected_to: [gym, park, street_south, street_west, factory]
|
| 225 |
+
|
| 226 |
# ===================
|
| 227 |
+
# STREETS — connectors (grid)
|
| 228 |
# ===================
|
| 229 |
- id: street_north
|
| 230 |
name: North Main Street
|
| 231 |
zone: public
|
| 232 |
description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
|
| 233 |
+
capacity: 50
|
| 234 |
+
connected_to: [house_elena, house_marcus, house_helen, house_diana, apartment_block_1, apartment_block_3, cafe, grocery, office, office_tower, bakery, park, school, hospital, church, town_square, street_south, street_east, street_west]
|
| 235 |
|
| 236 |
- id: street_south
|
| 237 |
name: South Main Street
|
| 238 |
zone: public
|
| 239 |
description: The southern stretch of main street. More residential, with the bar and gym nearby.
|
| 240 |
+
capacity: 50
|
| 241 |
+
connected_to: [house_james, house_rosa, house_yuki, house_frank, apartment_block_2, apartment_block_3, bar, grocery, gym, library, restaurant, cinema, factory, sports_field, town_square, hospital, street_north, street_east, street_west]
|
| 242 |
+
|
| 243 |
+
- id: street_east
|
| 244 |
+
name: East Avenue
|
| 245 |
+
zone: public
|
| 246 |
+
description: A quieter avenue on the east side of town, near the hospital and office towers.
|
| 247 |
+
capacity: 40
|
| 248 |
+
connected_to: [house_priya, office_tower, hospital, factory, street_north, street_south]
|
| 249 |
+
|
| 250 |
+
- id: street_west
|
| 251 |
+
name: West Avenue
|
| 252 |
+
zone: public
|
| 253 |
+
description: A tree-lined avenue on the west side, near the school and sports fields.
|
| 254 |
capacity: 40
|
| 255 |
+
connected_to: [house_kai, school, church, sports_field, street_north, street_south]
|
src/soci/agents/generator.py
CHANGED
|
@@ -69,26 +69,26 @@ LAST_NAMES = [
|
|
| 69 |
]
|
| 70 |
|
| 71 |
OCCUPATIONS = [
|
| 72 |
-
# White collar
|
| 73 |
-
("software engineer", "
|
| 74 |
-
("architect", "
|
| 75 |
-
("graphic designer", "
|
| 76 |
-
("financial advisor", "
|
| 77 |
-
# Blue collar
|
| 78 |
-
("mechanic", "
|
| 79 |
-
("construction worker", "
|
| 80 |
-
# Service / hospitality (evening shifts)
|
| 81 |
-
("bartender", "
|
| 82 |
-
("barista", "
|
| 83 |
-
# Creative
|
| 84 |
-
("writer", "
|
| 85 |
-
("artist", "
|
| 86 |
-
# Education
|
| 87 |
-
("teacher", "
|
| 88 |
-
# Health
|
| 89 |
-
("nurse", "
|
| 90 |
# Student / retired
|
| 91 |
-
("college student", "
|
| 92 |
]
|
| 93 |
|
| 94 |
VALUES_POOL = [
|
|
@@ -149,11 +149,11 @@ def _pick_name(gender: str, used_names: set[str]) -> str:
|
|
| 149 |
|
| 150 |
|
| 151 |
def _pick_occupation(age: int) -> tuple[str, str | None]:
|
| 152 |
-
"""Pick occupation based on age. Returns (title,
|
| 153 |
if age >= 65 and random.random() < 0.7:
|
| 154 |
return "retired", None
|
| 155 |
if 18 <= age <= 22 and random.random() < 0.6:
|
| 156 |
-
return "college student", "
|
| 157 |
return random.choice(OCCUPATIONS)
|
| 158 |
|
| 159 |
|
|
@@ -293,22 +293,22 @@ def _llm_temperature(openness: int) -> float:
|
|
| 293 |
|
| 294 |
|
| 295 |
def _assign_locations(
|
| 296 |
-
persona_data: dict,
|
| 297 |
occupation: str,
|
| 298 |
-
|
| 299 |
residential_ids: list[str],
|
| 300 |
-
|
| 301 |
-
commercial_ids: list[str],
|
| 302 |
res_index: int,
|
| 303 |
) -> tuple[str, str]:
|
| 304 |
"""Assign home and work locations. Returns (home_id, work_id)."""
|
| 305 |
home_id = residential_ids[res_index % len(residential_ids)]
|
| 306 |
|
| 307 |
-
if occupation in RETIRED_OCCUPATIONS or
|
| 308 |
-
work_id = home_id # Retired folks
|
| 309 |
-
elif
|
| 310 |
-
work_id =
|
| 311 |
else:
|
|
|
|
|
|
|
| 312 |
work_id = random.choice(work_ids) if work_ids else home_id
|
| 313 |
|
| 314 |
return home_id, work_id
|
|
@@ -318,8 +318,6 @@ def generate_personas(count: int, city: City) -> list[Persona]:
|
|
| 318 |
"""Generate `count` unique personas with assigned home/work locations."""
|
| 319 |
# Gather location pools
|
| 320 |
residential_ids = [lid for lid, loc in city.locations.items() if loc.zone == "residential"]
|
| 321 |
-
work_ids = [lid for lid, loc in city.locations.items() if loc.zone == "work"]
|
| 322 |
-
commercial_ids = [lid for lid, loc in city.locations.items() if loc.zone == "commercial"]
|
| 323 |
|
| 324 |
if not residential_ids:
|
| 325 |
raise ValueError("City has no residential locations — cannot assign homes.")
|
|
@@ -331,7 +329,7 @@ def generate_personas(count: int, city: City) -> list[Persona]:
|
|
| 331 |
gender = _pick_gender()
|
| 332 |
name = _pick_name(gender, used_names)
|
| 333 |
age = random.randint(18, 75)
|
| 334 |
-
occupation,
|
| 335 |
traits = _generate_traits()
|
| 336 |
values = _pick_values(traits)
|
| 337 |
quirks = _pick_quirks()
|
|
@@ -340,12 +338,10 @@ def generate_personas(count: int, city: City) -> list[Persona]:
|
|
| 340 |
temperature = _llm_temperature(traits["openness"])
|
| 341 |
|
| 342 |
home_id, work_id = _assign_locations(
|
| 343 |
-
{},
|
| 344 |
occupation,
|
| 345 |
-
|
| 346 |
residential_ids,
|
| 347 |
-
|
| 348 |
-
commercial_ids,
|
| 349 |
i,
|
| 350 |
)
|
| 351 |
|
|
|
|
| 69 |
]
|
| 70 |
|
| 71 |
OCCUPATIONS = [
|
| 72 |
+
# White collar → office, office_tower
|
| 73 |
+
("software engineer", "office"), ("accountant", "office"), ("marketing manager", "office"),
|
| 74 |
+
("architect", "office"), ("data analyst", "office"), ("project manager", "office"),
|
| 75 |
+
("graphic designer", "office"), ("lawyer", "office_tower"), ("consultant", "office_tower"),
|
| 76 |
+
("financial advisor", "office_tower"),
|
| 77 |
+
# Blue collar → factory
|
| 78 |
+
("mechanic", "factory"), ("electrician", "factory"), ("plumber", "factory"),
|
| 79 |
+
("construction worker", "factory"),
|
| 80 |
+
# Service / hospitality (evening shifts) → commercial
|
| 81 |
+
("bartender", "bar"), ("chef", "restaurant"), ("waiter", "restaurant"),
|
| 82 |
+
("barista", "cafe"),
|
| 83 |
+
# Creative → office
|
| 84 |
+
("writer", "office"), ("musician", "office"), ("photographer", "office"),
|
| 85 |
+
("artist", "office"),
|
| 86 |
+
# Education → school
|
| 87 |
+
("teacher", "school"), ("professor", "school"), ("tutor", "school"),
|
| 88 |
+
# Health → hospital
|
| 89 |
+
("nurse", "hospital"), ("personal trainer", "gym"), ("therapist", "hospital"),
|
| 90 |
# Student / retired
|
| 91 |
+
("college student", "school"), ("retired", None),
|
| 92 |
]
|
| 93 |
|
| 94 |
VALUES_POOL = [
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
def _pick_occupation(age: int) -> tuple[str, str | None]:
|
| 152 |
+
"""Pick occupation based on age. Returns (title, work_location_id)."""
|
| 153 |
if age >= 65 and random.random() < 0.7:
|
| 154 |
return "retired", None
|
| 155 |
if 18 <= age <= 22 and random.random() < 0.6:
|
| 156 |
+
return "college student", "school"
|
| 157 |
return random.choice(OCCUPATIONS)
|
| 158 |
|
| 159 |
|
|
|
|
| 293 |
|
| 294 |
|
| 295 |
def _assign_locations(
|
|
|
|
| 296 |
occupation: str,
|
| 297 |
+
work_location_id: str | None,
|
| 298 |
residential_ids: list[str],
|
| 299 |
+
city_locations: dict,
|
|
|
|
| 300 |
res_index: int,
|
| 301 |
) -> tuple[str, str]:
|
| 302 |
"""Assign home and work locations. Returns (home_id, work_id)."""
|
| 303 |
home_id = residential_ids[res_index % len(residential_ids)]
|
| 304 |
|
| 305 |
+
if occupation in RETIRED_OCCUPATIONS or work_location_id is None:
|
| 306 |
+
work_id = home_id # Retired folks stay home
|
| 307 |
+
elif work_location_id in city_locations:
|
| 308 |
+
work_id = work_location_id
|
| 309 |
else:
|
| 310 |
+
# Fallback: find any work-zone location
|
| 311 |
+
work_ids = [lid for lid, loc in city_locations.items() if loc.zone == "work"]
|
| 312 |
work_id = random.choice(work_ids) if work_ids else home_id
|
| 313 |
|
| 314 |
return home_id, work_id
|
|
|
|
| 318 |
"""Generate `count` unique personas with assigned home/work locations."""
|
| 319 |
# Gather location pools
|
| 320 |
residential_ids = [lid for lid, loc in city.locations.items() if loc.zone == "residential"]
|
|
|
|
|
|
|
| 321 |
|
| 322 |
if not residential_ids:
|
| 323 |
raise ValueError("City has no residential locations — cannot assign homes.")
|
|
|
|
| 329 |
gender = _pick_gender()
|
| 330 |
name = _pick_name(gender, used_names)
|
| 331 |
age = random.randint(18, 75)
|
| 332 |
+
occupation, work_location_id = _pick_occupation(age)
|
| 333 |
traits = _generate_traits()
|
| 334 |
values = _pick_values(traits)
|
| 335 |
quirks = _pick_quirks()
|
|
|
|
| 338 |
temperature = _llm_temperature(traits["openness"])
|
| 339 |
|
| 340 |
home_id, work_id = _assign_locations(
|
|
|
|
| 341 |
occupation,
|
| 342 |
+
work_location_id,
|
| 343 |
residential_ids,
|
| 344 |
+
city.locations,
|
|
|
|
| 345 |
i,
|
| 346 |
)
|
| 347 |
|
src/soci/agents/routine.py
CHANGED
|
@@ -153,7 +153,7 @@ class DailyRoutine:
|
|
| 153 |
{"purpose": 0.3})
|
| 154 |
|
| 155 |
# Lunch — pick a food place or stay at work
|
| 156 |
-
food_places = ["cafe", "restaurant", "grocery"]
|
| 157 |
lunch_spot = self._rng.choice(food_places)
|
| 158 |
h, m = t // 60, t % 60
|
| 159 |
t = self._add(h, m, "move", lunch_spot, 1, f"Walking to lunch at {lunch_spot}",
|
|
@@ -289,7 +289,7 @@ class DailyRoutine:
|
|
| 289 |
t = self._add(h, m, "relax", home, gap_ticks, "Chilling at home",
|
| 290 |
{"comfort": 0.1, "fun": 0.05})
|
| 291 |
|
| 292 |
-
lunch_spot = self._rng.choice(["cafe", "restaurant", "park"])
|
| 293 |
h, m = t // 60, t % 60
|
| 294 |
t = self._add(h, m, "move", lunch_spot, 1, f"Heading to {lunch_spot}",
|
| 295 |
{})
|
|
@@ -301,7 +301,8 @@ class DailyRoutine:
|
|
| 301 |
if e >= 6:
|
| 302 |
# Social afternoon: visit multiple places
|
| 303 |
afternoon_places = self._rng.sample(
|
| 304 |
-
["park", "cafe", "bar", "gym", "library"
|
|
|
|
| 305 |
k=min(2, self._rng.randint(1, 2)),
|
| 306 |
)
|
| 307 |
for place in afternoon_places:
|
|
@@ -364,7 +365,7 @@ class DailyRoutine:
|
|
| 364 |
|
| 365 |
if e >= 6:
|
| 366 |
# Extroverts go out
|
| 367 |
-
venue = self._rng.choice(["bar", "restaurant", "park"])
|
| 368 |
h, m = t // 60, t % 60
|
| 369 |
t = self._add(h, m, "move", venue, 1, f"Heading to {venue}",
|
| 370 |
{})
|
|
@@ -393,13 +394,13 @@ class DailyRoutine:
|
|
| 393 |
"""Fill a leisure period with activities based on personality."""
|
| 394 |
activities = []
|
| 395 |
if persona.extraversion >= 6:
|
| 396 |
-
activities.extend(["park", "cafe", "gym"])
|
| 397 |
else:
|
| 398 |
-
activities.extend(["library", "park"])
|
| 399 |
if persona.conscientiousness >= 6:
|
| 400 |
activities.append("gym")
|
| 401 |
if persona.openness >= 6:
|
| 402 |
-
activities.extend(["library", "park"])
|
| 403 |
|
| 404 |
dest = self._rng.choice(activities)
|
| 405 |
available_ticks = max(0, (end_t - t) // 15)
|
|
@@ -422,12 +423,20 @@ class DailyRoutine:
|
|
| 422 |
"cafe": "Hanging out at the cafe",
|
| 423 |
"gym": "Working out at the gym",
|
| 424 |
"library": "Reading at the library",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
}.get(dest, f"Spending time at {dest}")
|
| 426 |
needs = {
|
| 427 |
"park": {"fun": 0.2, "comfort": 0.1},
|
| 428 |
"cafe": {"social": 0.2, "fun": 0.1},
|
| 429 |
"gym": {"energy": -0.1, "fun": 0.2},
|
| 430 |
"library": {"fun": 0.15, "comfort": 0.1},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}.get(dest, {"fun": 0.1})
|
| 432 |
h, m = t // 60, t % 60
|
| 433 |
t = self._add(h, m, act_type, dest, act_ticks, act_detail, needs)
|
|
|
|
| 153 |
{"purpose": 0.3})
|
| 154 |
|
| 155 |
# Lunch — pick a food place or stay at work
|
| 156 |
+
food_places = ["cafe", "restaurant", "grocery", "bakery"]
|
| 157 |
lunch_spot = self._rng.choice(food_places)
|
| 158 |
h, m = t // 60, t % 60
|
| 159 |
t = self._add(h, m, "move", lunch_spot, 1, f"Walking to lunch at {lunch_spot}",
|
|
|
|
| 289 |
t = self._add(h, m, "relax", home, gap_ticks, "Chilling at home",
|
| 290 |
{"comfort": 0.1, "fun": 0.05})
|
| 291 |
|
| 292 |
+
lunch_spot = self._rng.choice(["cafe", "restaurant", "park", "bakery", "town_square"])
|
| 293 |
h, m = t // 60, t % 60
|
| 294 |
t = self._add(h, m, "move", lunch_spot, 1, f"Heading to {lunch_spot}",
|
| 295 |
{})
|
|
|
|
| 301 |
if e >= 6:
|
| 302 |
# Social afternoon: visit multiple places
|
| 303 |
afternoon_places = self._rng.sample(
|
| 304 |
+
["park", "cafe", "bar", "gym", "library", "cinema",
|
| 305 |
+
"town_square", "sports_field"],
|
| 306 |
k=min(2, self._rng.randint(1, 2)),
|
| 307 |
)
|
| 308 |
for place in afternoon_places:
|
|
|
|
| 365 |
|
| 366 |
if e >= 6:
|
| 367 |
# Extroverts go out
|
| 368 |
+
venue = self._rng.choice(["bar", "restaurant", "park", "cinema", "town_square"])
|
| 369 |
h, m = t // 60, t % 60
|
| 370 |
t = self._add(h, m, "move", venue, 1, f"Heading to {venue}",
|
| 371 |
{})
|
|
|
|
| 394 |
"""Fill a leisure period with activities based on personality."""
|
| 395 |
activities = []
|
| 396 |
if persona.extraversion >= 6:
|
| 397 |
+
activities.extend(["park", "cafe", "gym", "town_square", "sports_field"])
|
| 398 |
else:
|
| 399 |
+
activities.extend(["library", "park", "church"])
|
| 400 |
if persona.conscientiousness >= 6:
|
| 401 |
activities.append("gym")
|
| 402 |
if persona.openness >= 6:
|
| 403 |
+
activities.extend(["library", "park", "cinema"])
|
| 404 |
|
| 405 |
dest = self._rng.choice(activities)
|
| 406 |
available_ticks = max(0, (end_t - t) // 15)
|
|
|
|
| 423 |
"cafe": "Hanging out at the cafe",
|
| 424 |
"gym": "Working out at the gym",
|
| 425 |
"library": "Reading at the library",
|
| 426 |
+
"cinema": "Watching a movie",
|
| 427 |
+
"town_square": "People-watching at the square",
|
| 428 |
+
"sports_field": "Playing sports at the field",
|
| 429 |
+
"church": "Quiet time at the church",
|
| 430 |
}.get(dest, f"Spending time at {dest}")
|
| 431 |
needs = {
|
| 432 |
"park": {"fun": 0.2, "comfort": 0.1},
|
| 433 |
"cafe": {"social": 0.2, "fun": 0.1},
|
| 434 |
"gym": {"energy": -0.1, "fun": 0.2},
|
| 435 |
"library": {"fun": 0.15, "comfort": 0.1},
|
| 436 |
+
"cinema": {"fun": 0.3, "social": 0.1},
|
| 437 |
+
"town_square": {"social": 0.2, "fun": 0.15},
|
| 438 |
+
"sports_field": {"fun": 0.25, "energy": -0.1},
|
| 439 |
+
"church": {"comfort": 0.2, "purpose": 0.1},
|
| 440 |
}.get(dest, {"fun": 0.1})
|
| 441 |
h, m = t // 60, t % 60
|
| 442 |
t = self._add(h, m, act_type, dest, act_ticks, act_detail, needs)
|
web/index.html
CHANGED
|
@@ -33,7 +33,7 @@
|
|
| 33 |
|
| 34 |
/* SIDEBAR */
|
| 35 |
#sidebar {
|
| 36 |
-
width:
|
| 37 |
display: flex; flex-direction: column; overflow: hidden;
|
| 38 |
}
|
| 39 |
.sidebar-tabs {
|
|
@@ -175,6 +175,13 @@
|
|
| 175 |
<canvas id="cityCanvas"></canvas>
|
| 176 |
<div id="tooltip"></div>
|
| 177 |
<div id="toast-container"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
<div id="sidebar">
|
| 180 |
<div class="sidebar-tabs">
|
|
@@ -202,52 +209,81 @@ const POLL_INTERVAL = 2000;
|
|
| 202 |
const HORIZON = 0.14;
|
| 203 |
|
| 204 |
// --- CITY LAYOUT ---
|
| 205 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
const ROADS = [
|
| 207 |
// Horizontal roads
|
| 208 |
-
{ x1: 0.
|
| 209 |
-
{ x1: 0.
|
| 210 |
// Main vertical street
|
| 211 |
-
{ x1: 0.50, y1: 0.
|
| 212 |
-
// West
|
| 213 |
-
{ x1: 0.
|
| 214 |
-
// East
|
| 215 |
-
{ x1: 0.
|
| 216 |
-
//
|
| 217 |
-
{ x1: 0.
|
| 218 |
-
{ x1: 0.50, y1: 0.
|
| 219 |
-
{ x1: 0.
|
| 220 |
-
{ x1: 0.50, y1: 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
];
|
| 222 |
|
| 223 |
-
// Building positions —
|
| 224 |
const LOCATION_POSITIONS = {
|
| 225 |
-
//
|
| 226 |
-
house_elena: { x: 0.
|
| 227 |
-
house_marcus: { x: 0.
|
| 228 |
-
house_helen: { x: 0.
|
| 229 |
-
house_diana: { x: 0.
|
| 230 |
-
// Middle houses
|
| 231 |
-
house_kai: { x: 0.
|
| 232 |
-
house_priya: { x: 0.
|
| 233 |
-
//
|
| 234 |
-
house_james: { x: 0.
|
| 235 |
-
house_rosa: { x: 0.
|
| 236 |
-
house_yuki: { x: 0.
|
| 237 |
-
house_frank: { x: 0.
|
| 238 |
-
//
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
};
|
| 252 |
|
| 253 |
const AGENT_COLORS = [
|
|
@@ -300,6 +336,10 @@ let stars = [];
|
|
| 300 |
let activeTab = 'agents';
|
| 301 |
let agentIdxMap = {};
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
// Tree / decorations cache
|
| 304 |
let trees = [];
|
| 305 |
let streetLamps = [];
|
|
@@ -313,10 +353,11 @@ function initParticles() {
|
|
| 313 |
stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
|
| 314 |
// Scatter trees in green spaces between buildings
|
| 315 |
const treeZones = [
|
| 316 |
-
{cx:0.50, cy:0.
|
| 317 |
-
{cx:0.
|
| 318 |
-
{cx:0.
|
| 319 |
-
{cx:0.
|
|
|
|
| 320 |
];
|
| 321 |
for (const z of treeZones) {
|
| 322 |
for (let i = 0; i < z.count; i++) {
|
|
@@ -329,9 +370,11 @@ function initParticles() {
|
|
| 329 |
}
|
| 330 |
}
|
| 331 |
// Street lamps along roads
|
| 332 |
-
for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.
|
| 333 |
-
for (let i = 0; i <
|
| 334 |
-
for (let i = 0; i <
|
|
|
|
|
|
|
| 335 |
}
|
| 336 |
|
| 337 |
// ============================================================
|
|
@@ -354,6 +397,10 @@ function initCanvas() {
|
|
| 354 |
window.addEventListener('resize', resizeCanvas);
|
| 355 |
canvas.addEventListener('click', onCanvasClick);
|
| 356 |
canvas.addEventListener('mousemove', onCanvasMouseMove);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
initParticles();
|
| 358 |
requestAnimationFrame(animate);
|
| 359 |
}
|
|
@@ -363,6 +410,40 @@ function resizeCanvas() {
|
|
| 363 |
canvas.height = c.clientHeight;
|
| 364 |
}
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
function animate() {
|
| 367 |
animFrame++;
|
| 368 |
for (const [id, target] of Object.entries(agentTargets)) {
|
|
@@ -380,17 +461,24 @@ function animate() {
|
|
| 380 |
// ============================================================
|
| 381 |
function draw() {
|
| 382 |
if (!ctx) return;
|
| 383 |
-
const W =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
|
| 385 |
-
drawSky(W, H);
|
| 386 |
drawGround(W, H);
|
| 387 |
drawWeather(W, H);
|
| 388 |
drawRoads(W, H);
|
| 389 |
drawSidewalks(W, H);
|
| 390 |
drawTrees(W, H);
|
| 391 |
|
| 392 |
-
// Draw buildings
|
| 393 |
-
|
|
|
|
| 394 |
if (pos.type !== 'street') drawBuilding(id, pos, W, H);
|
| 395 |
}
|
| 396 |
|
|
@@ -418,6 +506,35 @@ function draw() {
|
|
| 418 |
ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
|
| 419 |
ctx.fillRect(0, 0, W, H);
|
| 420 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}
|
| 422 |
|
| 423 |
function getAgentIdx(id) {
|
|
@@ -429,6 +546,7 @@ function getAgentIdx(id) {
|
|
| 429 |
// SKY
|
| 430 |
// ============================================================
|
| 431 |
function drawSky(W, H) {
|
|
|
|
| 432 |
const s = SKY[currentTimeOfDay] || SKY.morning;
|
| 433 |
const hLine = H * HORIZON;
|
| 434 |
const grad = ctx.createLinearGradient(0, 0, 0, hLine);
|
|
@@ -474,7 +592,7 @@ function drawMoon(x, y, r) {
|
|
| 474 |
// GROUND
|
| 475 |
// ============================================================
|
| 476 |
function drawGround(W, H) {
|
| 477 |
-
const hLine =
|
| 478 |
const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
|
| 479 |
const grad = ctx.createLinearGradient(0, hLine, 0, H);
|
| 480 |
const bc = hexToRgb(gt.base);
|
|
@@ -670,22 +788,32 @@ function drawBuilding(id, pos, W, H) {
|
|
| 670 |
if (pos.type === 'house') drawHouse(x, y, isDark, id);
|
| 671 |
else if (pos.type === 'shop') drawShop(x, y, isDark);
|
| 672 |
else if (pos.type === 'office') drawOffice(x, y, isDark);
|
|
|
|
| 673 |
else if (pos.type === 'park') drawPark(x, y, isDark);
|
| 674 |
else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
// Label
|
| 677 |
-
if (pos.type !== 'park') {
|
| 678 |
const label = pos.label || id;
|
| 679 |
const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
|
| 680 |
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 681 |
-
const ly = pos.type === 'house' ? y + 18 : y + 25;
|
| 682 |
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
|
| 683 |
ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
|
| 684 |
}
|
| 685 |
|
| 686 |
// Occupant count badge
|
| 687 |
if (occ > 0) {
|
| 688 |
-
const
|
|
|
|
| 689 |
ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
|
| 690 |
ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 691 |
ctx.fillText(occ.toString(), bx, by);
|
|
@@ -835,6 +963,257 @@ function drawPark(x, y, dk) {
|
|
| 835 |
ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
|
| 836 |
}
|
| 837 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
function dim(hex, f) {
|
| 839 |
if (hex.startsWith('rgb')) return hex; // already rgb
|
| 840 |
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
|
|
@@ -907,7 +1286,8 @@ function drawConversationBubbles(W, H) {
|
|
| 907 |
// ============================================================
|
| 908 |
function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
|
| 909 |
const loc = agent.location || 'house_elena';
|
| 910 |
-
const
|
|
|
|
| 911 |
if (!pos) return;
|
| 912 |
|
| 913 |
const atLoc = byLoc[loc] || [];
|
|
@@ -916,22 +1296,29 @@ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
|
|
| 916 |
|
| 917 |
// For streets, spread agents along the road
|
| 918 |
if (pos.type === 'street') {
|
|
|
|
|
|
|
| 919 |
const spread = Math.min(count, 10);
|
| 920 |
-
const step = 0.
|
| 921 |
const startX = pos.x - (spread-1)*step/2;
|
| 922 |
agentTargets[id] = {
|
| 923 |
-
x: (startX +
|
| 924 |
-
y: pos.y * H + 16 + (
|
| 925 |
};
|
| 926 |
} else {
|
| 927 |
-
//
|
| 928 |
-
const
|
| 929 |
-
const
|
| 930 |
-
const
|
| 931 |
-
const
|
| 932 |
-
const
|
| 933 |
-
|
| 934 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 935 |
}
|
| 936 |
if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
|
| 937 |
}
|
|
@@ -1054,8 +1441,9 @@ function drawPerson(id, agent, globalIdx, W, H) {
|
|
| 1054 |
// INTERACTION
|
| 1055 |
// ============================================================
|
| 1056 |
function onCanvasClick(e) {
|
|
|
|
| 1057 |
const rect=canvas.getBoundingClientRect();
|
| 1058 |
-
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
| 1059 |
let clicked=null, minD=24;
|
| 1060 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1061 |
const d=Math.hypot(mx-pos.x,my-pos.y);
|
|
@@ -1073,10 +1461,12 @@ function onCanvasClick(e) {
|
|
| 1073 |
|
| 1074 |
function onCanvasMouseMove(e) {
|
| 1075 |
const rect=canvas.getBoundingClientRect();
|
| 1076 |
-
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
|
| 1077 |
-
const W=
|
| 1078 |
const tt=document.getElementById('tooltip');
|
| 1079 |
|
|
|
|
|
|
|
| 1080 |
let foundAgent=null;
|
| 1081 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1082 |
if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
|
|
@@ -1084,7 +1474,8 @@ function onCanvasMouseMove(e) {
|
|
| 1084 |
|
| 1085 |
let foundLoc=null;
|
| 1086 |
if (!foundAgent) {
|
| 1087 |
-
|
|
|
|
| 1088 |
const lx=pos.x*W, ly=pos.y*H;
|
| 1089 |
const hitR = pos.type === 'house' ? 25 : 35;
|
| 1090 |
if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}
|
|
|
|
| 33 |
|
| 34 |
/* SIDEBAR */
|
| 35 |
#sidebar {
|
| 36 |
+
width: 260px; background: #16213e; border-left: 2px solid #0f3460;
|
| 37 |
display: flex; flex-direction: column; overflow: hidden;
|
| 38 |
}
|
| 39 |
.sidebar-tabs {
|
|
|
|
| 175 |
<canvas id="cityCanvas"></canvas>
|
| 176 |
<div id="tooltip"></div>
|
| 177 |
<div id="toast-container"></div>
|
| 178 |
+
<input type="range" id="pan-x" min="0" max="100" value="0"
|
| 179 |
+
style="position:absolute;bottom:4px;left:10px;right:10px;width:calc(100% - 20px);height:14px;opacity:0.5;z-index:50;"
|
| 180 |
+
oninput="onPanSlider()">
|
| 181 |
+
<input type="range" id="pan-y" min="0" max="100" value="0"
|
| 182 |
+
orient="vertical"
|
| 183 |
+
style="position:absolute;right:4px;top:10px;bottom:24px;width:14px;height:calc(100% - 34px);opacity:0.5;z-index:50;writing-mode:vertical-lr;direction:ltr;-webkit-appearance:slider-vertical;"
|
| 184 |
+
oninput="onPanSlider()">
|
| 185 |
</div>
|
| 186 |
<div id="sidebar">
|
| 187 |
<div class="sidebar-tabs">
|
|
|
|
| 209 |
const HORIZON = 0.14;
|
| 210 |
|
| 211 |
// --- CITY LAYOUT ---
|
| 212 |
+
// World dimensions (normalized) — larger than viewport, scrollable
|
| 213 |
+
const WORLD_W = 1.6;
|
| 214 |
+
const WORLD_H = 1.4;
|
| 215 |
+
|
| 216 |
+
// Road network
|
| 217 |
const ROADS = [
|
| 218 |
// Horizontal roads
|
| 219 |
+
{ x1: 0.03, y1: 0.28, x2: 0.97, y2: 0.28, width: 14, name: 'North Road' },
|
| 220 |
+
{ x1: 0.03, y1: 0.58, x2: 0.97, y2: 0.58, width: 14, name: 'South Road' },
|
| 221 |
// Main vertical street
|
| 222 |
+
{ x1: 0.50, y1: 0.13, x2: 0.50, y2: 0.90, width: 16, name: 'Main Street' },
|
| 223 |
+
// West avenue
|
| 224 |
+
{ x1: 0.15, y1: 0.20, x2: 0.15, y2: 0.82, width: 10 },
|
| 225 |
+
// East avenue
|
| 226 |
+
{ x1: 0.85, y1: 0.20, x2: 0.85, y2: 0.82, width: 10 },
|
| 227 |
+
// Mid connectors
|
| 228 |
+
{ x1: 0.15, y1: 0.43, x2: 0.50, y2: 0.43, width: 6 },
|
| 229 |
+
{ x1: 0.50, y1: 0.43, x2: 0.85, y2: 0.43, width: 6 },
|
| 230 |
+
{ x1: 0.15, y1: 0.72, x2: 0.50, y2: 0.72, width: 6 },
|
| 231 |
+
{ x1: 0.50, y1: 0.72, x2: 0.85, y2: 0.72, width: 6 },
|
| 232 |
+
// Extra connectors
|
| 233 |
+
{ x1: 0.30, y1: 0.28, x2: 0.30, y2: 0.43, width: 6 },
|
| 234 |
+
{ x1: 0.70, y1: 0.28, x2: 0.70, y2: 0.43, width: 6 },
|
| 235 |
+
{ x1: 0.30, y1: 0.58, x2: 0.30, y2: 0.72, width: 6 },
|
| 236 |
+
{ x1: 0.70, y1: 0.58, x2: 0.70, y2: 0.72, width: 6 },
|
| 237 |
];
|
| 238 |
|
| 239 |
+
// Building positions — spread across larger grid
|
| 240 |
const LOCATION_POSITIONS = {
|
| 241 |
+
// === RESIDENTIAL — Row 1: North houses ===
|
| 242 |
+
house_elena: { x: 0.08, y: 0.19, type: 'house', label: 'Elena & Lila' },
|
| 243 |
+
house_marcus: { x: 0.24, y: 0.19, type: 'house', label: 'Marcus & Zoe' },
|
| 244 |
+
house_helen: { x: 0.62, y: 0.19, type: 'house', label: 'Helen & Alice' },
|
| 245 |
+
house_diana: { x: 0.78, y: 0.19, type: 'house', label: 'Diana & Marco' },
|
| 246 |
+
// Row 2: Middle houses
|
| 247 |
+
house_kai: { x: 0.08, y: 0.36, type: 'house', label: "Kai's Studio" },
|
| 248 |
+
house_priya: { x: 0.92, y: 0.36, type: 'house', label: 'Priya & Nina' },
|
| 249 |
+
// Row 3: South houses
|
| 250 |
+
house_james: { x: 0.08, y: 0.65, type: 'house', label: 'James & Theo' },
|
| 251 |
+
house_rosa: { x: 0.24, y: 0.65, type: 'house', label: 'Rosa & Omar' },
|
| 252 |
+
house_yuki: { x: 0.62, y: 0.65, type: 'house', label: 'Yuki & Devon' },
|
| 253 |
+
house_frank: { x: 0.78, y: 0.65, type: 'house', label: 'Frank+George+Sam' },
|
| 254 |
+
// Apartment blocks
|
| 255 |
+
apartment_block_1: { x: 0.40, y: 0.19, type: 'apartment', label: 'Northside Apts' },
|
| 256 |
+
apartment_block_2: { x: 0.40, y: 0.65, type: 'apartment', label: 'Southside Apts' },
|
| 257 |
+
apartment_block_3: { x: 0.56, y: 0.50, type: 'apartment', label: 'Central Apts' },
|
| 258 |
+
|
| 259 |
+
// === COMMERCIAL ===
|
| 260 |
+
cafe: { x: 0.35, y: 0.34, type: 'shop', label: 'The Daily Grind' },
|
| 261 |
+
grocery: { x: 0.65, y: 0.34, type: 'shop', label: 'Green Basket' },
|
| 262 |
+
bakery: { x: 0.22, y: 0.34, type: 'shop', label: 'Golden Crust' },
|
| 263 |
+
restaurant: { x: 0.35, y: 0.50, type: 'shop', label: "Mama Rosa's" },
|
| 264 |
+
bar: { x: 0.65, y: 0.50, type: 'shop', label: 'Rusty Anchor' },
|
| 265 |
+
cinema: { x: 0.78, y: 0.50, type: 'cinema', label: 'Starlight Cinema' },
|
| 266 |
+
|
| 267 |
+
// === WORK ===
|
| 268 |
+
office: { x: 0.50, y: 0.34, type: 'office', label: 'The Hive' },
|
| 269 |
+
office_tower: { x: 0.80, y: 0.34, type: 'tower', label: 'Pinnacle Tower' },
|
| 270 |
+
factory: { x: 0.92, y: 0.65, type: 'factory', label: 'Ironworks' },
|
| 271 |
+
school: { x: 0.08, y: 0.50, type: 'school', label: 'Soci School' },
|
| 272 |
+
hospital: { x: 0.92, y: 0.50, type: 'hospital', label: 'City Hospital' },
|
| 273 |
+
|
| 274 |
+
// === PUBLIC ===
|
| 275 |
+
park: { x: 0.50, y: 0.19, type: 'park', label: 'Willow Park' },
|
| 276 |
+
gym: { x: 0.22, y: 0.50, type: 'public', label: 'Iron & Grit' },
|
| 277 |
+
library: { x: 0.78, y: 0.78, type: 'public', label: 'Public Library' },
|
| 278 |
+
church: { x: 0.08, y: 0.78, type: 'church', label: "St. Mary's" },
|
| 279 |
+
town_square: { x: 0.50, y: 0.50, type: 'square', label: 'Town Square' },
|
| 280 |
+
sports_field: { x: 0.22, y: 0.78, type: 'sports', label: 'Sports Field' },
|
| 281 |
+
|
| 282 |
+
// === STREETS ===
|
| 283 |
+
street_north: { x: 0.50, y: 0.28, type: 'street', label: 'N. Main St' },
|
| 284 |
+
street_south: { x: 0.50, y: 0.58, type: 'street', label: 'S. Main St' },
|
| 285 |
+
street_east: { x: 0.85, y: 0.43, type: 'street', label: 'East Ave' },
|
| 286 |
+
street_west: { x: 0.15, y: 0.43, type: 'street', label: 'West Ave' },
|
| 287 |
};
|
| 288 |
|
| 289 |
const AGENT_COLORS = [
|
|
|
|
| 336 |
let activeTab = 'agents';
|
| 337 |
let agentIdxMap = {};
|
| 338 |
|
| 339 |
+
// Pan state
|
| 340 |
+
let panX = 0, panY = 0;
|
| 341 |
+
let isDragging = false, dragStartX = 0, dragStartY = 0, dragPanStartX = 0, dragPanStartY = 0;
|
| 342 |
+
|
| 343 |
// Tree / decorations cache
|
| 344 |
let trees = [];
|
| 345 |
let streetLamps = [];
|
|
|
|
| 353 |
stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
|
| 354 |
// Scatter trees in green spaces between buildings
|
| 355 |
const treeZones = [
|
| 356 |
+
{cx:0.50, cy:0.19, rx:0.10, ry:0.03, count:8}, // park
|
| 357 |
+
{cx:0.22, cy:0.78, rx:0.06, ry:0.03, count:5}, // sports field
|
| 358 |
+
{cx:0.50, cy:0.50, rx:0.04, ry:0.03, count:4}, // town square
|
| 359 |
+
{cx:0.08, cy:0.78, rx:0.03, ry:0.02, count:3}, // church garden
|
| 360 |
+
{cx:0.50, cy:0.85, rx:0.15, ry:0.03, count:5}, // south green
|
| 361 |
];
|
| 362 |
for (const z of treeZones) {
|
| 363 |
for (let i = 0; i < z.count; i++) {
|
|
|
|
| 370 |
}
|
| 371 |
}
|
| 372 |
// Street lamps along roads
|
| 373 |
+
for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.16 + i*0.10 });
|
| 374 |
+
for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.28 });
|
| 375 |
+
for (let i = 0; i < 5; i++) streetLamps.push({ x: 0.06 + i*0.22, y: 0.58 });
|
| 376 |
+
for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.15, y: 0.25 + i*0.18 });
|
| 377 |
+
for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.85, y: 0.25 + i*0.18 });
|
| 378 |
}
|
| 379 |
|
| 380 |
// ============================================================
|
|
|
|
| 397 |
window.addEventListener('resize', resizeCanvas);
|
| 398 |
canvas.addEventListener('click', onCanvasClick);
|
| 399 |
canvas.addEventListener('mousemove', onCanvasMouseMove);
|
| 400 |
+
canvas.addEventListener('mousedown', onCanvasDragStart);
|
| 401 |
+
canvas.addEventListener('mousemove', onCanvasDrag);
|
| 402 |
+
canvas.addEventListener('mouseup', onCanvasDragEnd);
|
| 403 |
+
canvas.addEventListener('mouseleave', onCanvasDragEnd);
|
| 404 |
initParticles();
|
| 405 |
requestAnimationFrame(animate);
|
| 406 |
}
|
|
|
|
| 410 |
canvas.height = c.clientHeight;
|
| 411 |
}
|
| 412 |
|
| 413 |
+
// World size in pixels (larger than canvas)
|
| 414 |
+
function worldW() { return canvas.width * WORLD_W; }
|
| 415 |
+
function worldH() { return canvas.height * WORLD_H; }
|
| 416 |
+
function maxPanX() { return Math.max(0, worldW() - canvas.width); }
|
| 417 |
+
function maxPanY() { return Math.max(0, worldH() - canvas.height); }
|
| 418 |
+
|
| 419 |
+
function onPanSlider() {
|
| 420 |
+
const sx = document.getElementById('pan-x');
|
| 421 |
+
const sy = document.getElementById('pan-y');
|
| 422 |
+
panX = (sx.value / 100) * maxPanX();
|
| 423 |
+
panY = (sy.value / 100) * maxPanY();
|
| 424 |
+
}
|
| 425 |
+
function syncSliders() {
|
| 426 |
+
const sx = document.getElementById('pan-x');
|
| 427 |
+
const sy = document.getElementById('pan-y');
|
| 428 |
+
if (sx) sx.value = maxPanX() > 0 ? (panX / maxPanX()) * 100 : 0;
|
| 429 |
+
if (sy) sy.value = maxPanY() > 0 ? (panY / maxPanY()) * 100 : 0;
|
| 430 |
+
}
|
| 431 |
+
function onCanvasDragStart(e) {
|
| 432 |
+
if (e.button !== 0) return;
|
| 433 |
+
isDragging = true;
|
| 434 |
+
dragStartX = e.clientX; dragStartY = e.clientY;
|
| 435 |
+
dragPanStartX = panX; dragPanStartY = panY;
|
| 436 |
+
}
|
| 437 |
+
function onCanvasDrag(e) {
|
| 438 |
+
if (!isDragging) return;
|
| 439 |
+
const dx = dragStartX - e.clientX, dy = dragStartY - e.clientY;
|
| 440 |
+
if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
|
| 441 |
+
panX = Math.max(0, Math.min(maxPanX(), dragPanStartX + dx));
|
| 442 |
+
panY = Math.max(0, Math.min(maxPanY(), dragPanStartY + dy));
|
| 443 |
+
syncSliders();
|
| 444 |
+
}
|
| 445 |
+
function onCanvasDragEnd() { isDragging = false; }
|
| 446 |
+
|
| 447 |
function animate() {
|
| 448 |
animFrame++;
|
| 449 |
for (const [id, target] of Object.entries(agentTargets)) {
|
|
|
|
| 461 |
// ============================================================
|
| 462 |
function draw() {
|
| 463 |
if (!ctx) return;
|
| 464 |
+
const W = worldW(), H = worldH();
|
| 465 |
+
const cW = canvas.width, cH = canvas.height;
|
| 466 |
+
|
| 467 |
+
// Sky and weather drawn without pan (full canvas)
|
| 468 |
+
drawSky(cW, cH);
|
| 469 |
+
|
| 470 |
+
ctx.save();
|
| 471 |
+
ctx.translate(-panX, -panY);
|
| 472 |
|
|
|
|
| 473 |
drawGround(W, H);
|
| 474 |
drawWeather(W, H);
|
| 475 |
drawRoads(W, H);
|
| 476 |
drawSidewalks(W, H);
|
| 477 |
drawTrees(W, H);
|
| 478 |
|
| 479 |
+
// Draw buildings — known positions + auto-generated houses
|
| 480 |
+
const allPositions = getEffectivePositions();
|
| 481 |
+
for (const [id, pos] of Object.entries(allPositions)) {
|
| 482 |
if (pos.type !== 'street') drawBuilding(id, pos, W, H);
|
| 483 |
}
|
| 484 |
|
|
|
|
| 506 |
ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
|
| 507 |
ctx.fillRect(0, 0, W, H);
|
| 508 |
}
|
| 509 |
+
|
| 510 |
+
ctx.restore();
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
// Auto-compute positions for generated houses not in LOCATION_POSITIONS
|
| 514 |
+
let _genPosCache = {};
|
| 515 |
+
function getEffectivePositions() {
|
| 516 |
+
const result = {...LOCATION_POSITIONS};
|
| 517 |
+
// Add any locations from server data that we don't have positions for
|
| 518 |
+
for (const locId of Object.keys(locations)) {
|
| 519 |
+
if (!result[locId]) {
|
| 520 |
+
if (!_genPosCache[locId]) {
|
| 521 |
+
// Hash-based position in residential rows
|
| 522 |
+
let h = 0;
|
| 523 |
+
for (let i = 0; i < locId.length; i++) h = ((h << 5) - h + locId.charCodeAt(i)) | 0;
|
| 524 |
+
const col = ((h >>> 0) % 12) / 12;
|
| 525 |
+
const row = ((h >>> 4) % 3);
|
| 526 |
+
const rowY = [0.82, 0.86, 0.90][row];
|
| 527 |
+
_genPosCache[locId] = {
|
| 528 |
+
x: 0.06 + col * 0.88,
|
| 529 |
+
y: rowY + ((h >>> 8) % 3) * 0.01,
|
| 530 |
+
type: 'house',
|
| 531 |
+
label: (locations[locId]?.name || locId).slice(0, 14),
|
| 532 |
+
};
|
| 533 |
+
}
|
| 534 |
+
result[locId] = _genPosCache[locId];
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
return result;
|
| 538 |
}
|
| 539 |
|
| 540 |
function getAgentIdx(id) {
|
|
|
|
| 546 |
// SKY
|
| 547 |
// ============================================================
|
| 548 |
function drawSky(W, H) {
|
| 549 |
+
// Sky is always drawn at canvas size (before pan transform)
|
| 550 |
const s = SKY[currentTimeOfDay] || SKY.morning;
|
| 551 |
const hLine = H * HORIZON;
|
| 552 |
const grad = ctx.createLinearGradient(0, 0, 0, hLine);
|
|
|
|
| 592 |
// GROUND
|
| 593 |
// ============================================================
|
| 594 |
function drawGround(W, H) {
|
| 595 |
+
const hLine = (canvas.height * HORIZON);
|
| 596 |
const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
|
| 597 |
const grad = ctx.createLinearGradient(0, hLine, 0, H);
|
| 598 |
const bc = hexToRgb(gt.base);
|
|
|
|
| 788 |
if (pos.type === 'house') drawHouse(x, y, isDark, id);
|
| 789 |
else if (pos.type === 'shop') drawShop(x, y, isDark);
|
| 790 |
else if (pos.type === 'office') drawOffice(x, y, isDark);
|
| 791 |
+
else if (pos.type === 'tower') drawTower(x, y, isDark);
|
| 792 |
else if (pos.type === 'park') drawPark(x, y, isDark);
|
| 793 |
else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
|
| 794 |
+
else if (pos.type === 'factory') drawFactory(x, y, isDark);
|
| 795 |
+
else if (pos.type === 'school') drawSchool(x, y, isDark);
|
| 796 |
+
else if (pos.type === 'hospital') drawHospital(x, y, isDark);
|
| 797 |
+
else if (pos.type === 'church') drawChurch(x, y, isDark);
|
| 798 |
+
else if (pos.type === 'cinema') drawCinema(x, y, isDark);
|
| 799 |
+
else if (pos.type === 'apartment') drawApartment(x, y, isDark);
|
| 800 |
+
else if (pos.type === 'square') drawSquare(x, y, isDark);
|
| 801 |
+
else if (pos.type === 'sports') drawSportsField(x, y, isDark);
|
| 802 |
|
| 803 |
// Label
|
| 804 |
+
if (pos.type !== 'park' && pos.type !== 'square' && pos.type !== 'sports') {
|
| 805 |
const label = pos.label || id;
|
| 806 |
const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
|
| 807 |
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 808 |
+
const ly = pos.type === 'house' ? y + 18 : (pos.type === 'tower' ? y + 35 : y + 25);
|
| 809 |
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
|
| 810 |
ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
|
| 811 |
}
|
| 812 |
|
| 813 |
// Occupant count badge
|
| 814 |
if (occ > 0) {
|
| 815 |
+
const byOff = pos.type === 'house' ? 14 : (pos.type === 'tower' ? 32 : (pos.type === 'church' ? 40 : 18));
|
| 816 |
+
const bx = x + (pos.type === 'house' ? 22 : 28), by = y - byOff;
|
| 817 |
ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
|
| 818 |
ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 819 |
ctx.fillText(occ.toString(), bx, by);
|
|
|
|
| 963 |
ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
|
| 964 |
}
|
| 965 |
|
| 966 |
+
function drawTower(x, y, dk) {
|
| 967 |
+
const w = 36, h = 56;
|
| 968 |
+
// Glass facade
|
| 969 |
+
ctx.fillStyle = dk ? '#1e2038' : '#6880a0';
|
| 970 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 971 |
+
// Top cap
|
| 972 |
+
ctx.fillStyle = dk ? '#141828' : '#506880';
|
| 973 |
+
ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
|
| 974 |
+
// Antenna
|
| 975 |
+
ctx.fillStyle = '#888'; ctx.fillRect(x-1, y-h/2-12, 2, 10);
|
| 976 |
+
ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(x, y-h/2-12, 2, 0, 6.28); ctx.fill();
|
| 977 |
+
// Door
|
| 978 |
+
ctx.fillStyle = dk ? '#0a0e18' : '#384858';
|
| 979 |
+
ctx.fillRect(x-5, y+h/2-14, 10, 14);
|
| 980 |
+
// Window grid (5 rows x 4 cols)
|
| 981 |
+
const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.5)';
|
| 982 |
+
ctx.fillStyle = wc;
|
| 983 |
+
for (let r = 0; r < 5; r++)
|
| 984 |
+
for (let c = 0; c < 4; c++)
|
| 985 |
+
ctx.fillRect(x-w/2+3+c*8.5, y-h/2+5+r*10, 6, 6);
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
function drawFactory(x, y, dk) {
|
| 989 |
+
const w = 56, h = 34;
|
| 990 |
+
// Main building
|
| 991 |
+
ctx.fillStyle = dk ? '#2a2520' : '#8a7a6a';
|
| 992 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 993 |
+
// Saw-tooth roof
|
| 994 |
+
ctx.fillStyle = dk ? '#1a1815' : '#6a5a4a';
|
| 995 |
+
for (let i = 0; i < 3; i++) {
|
| 996 |
+
const rx = x - w/2 + i*(w/3);
|
| 997 |
+
ctx.beginPath();
|
| 998 |
+
ctx.moveTo(rx, y-h/2);
|
| 999 |
+
ctx.lineTo(rx + w/6, y-h/2-10);
|
| 1000 |
+
ctx.lineTo(rx + w/3, y-h/2);
|
| 1001 |
+
ctx.closePath(); ctx.fill();
|
| 1002 |
+
}
|
| 1003 |
+
// Chimney with smoke
|
| 1004 |
+
ctx.fillStyle = dk ? '#3a3020' : '#706050';
|
| 1005 |
+
ctx.fillRect(x+w/2-10, y-h/2-18, 8, 14);
|
| 1006 |
+
// Smoke
|
| 1007 |
+
ctx.fillStyle = `rgba(180,180,180,${dk?0.15:0.25})`;
|
| 1008 |
+
for (let i = 0; i < 3; i++) {
|
| 1009 |
+
const sy = y-h/2-22-i*8 + Math.sin(animFrame*0.03+i)*3;
|
| 1010 |
+
ctx.beginPath(); ctx.arc(x+w/2-6+i*3, sy, 4+i*2, 0, 6.28); ctx.fill();
|
| 1011 |
+
}
|
| 1012 |
+
// Loading door
|
| 1013 |
+
ctx.fillStyle = dk ? '#1a1815' : '#5a4a3a';
|
| 1014 |
+
ctx.fillRect(x-8, y+h/2-16, 16, 16);
|
| 1015 |
+
// Windows
|
| 1016 |
+
const wc = dk ? 'rgba(255,180,80,0.5)' : 'rgba(200,220,240,0.4)';
|
| 1017 |
+
ctx.fillStyle = wc;
|
| 1018 |
+
for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*14, y-h/2+6, 10, 8);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
function drawSchool(x, y, dk) {
|
| 1022 |
+
const w = 52, h = 36;
|
| 1023 |
+
// Main building
|
| 1024 |
+
ctx.fillStyle = dk ? '#2a2225' : '#c4a088';
|
| 1025 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 1026 |
+
// Roof
|
| 1027 |
+
ctx.fillStyle = dk ? '#1a1518' : '#8a5a3a';
|
| 1028 |
+
ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
|
| 1029 |
+
// Flagpole
|
| 1030 |
+
ctx.fillStyle = '#888'; ctx.fillRect(x+w/2-6, y-h/2-20, 2, 18);
|
| 1031 |
+
// Flag
|
| 1032 |
+
ctx.fillStyle = '#e94560';
|
| 1033 |
+
ctx.beginPath();
|
| 1034 |
+
ctx.moveTo(x+w/2-4, y-h/2-20);
|
| 1035 |
+
ctx.lineTo(x+w/2+8, y-h/2-16);
|
| 1036 |
+
ctx.lineTo(x+w/2-4, y-h/2-12);
|
| 1037 |
+
ctx.closePath(); ctx.fill();
|
| 1038 |
+
// Door
|
| 1039 |
+
ctx.fillStyle = dk ? '#1a1218' : '#6a4a3a';
|
| 1040 |
+
ctx.fillRect(x-5, y+h/2-14, 10, 14);
|
| 1041 |
+
// Windows (2 rows)
|
| 1042 |
+
const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(200,230,255,0.5)';
|
| 1043 |
+
ctx.fillStyle = wc;
|
| 1044 |
+
for (let r = 0; r < 2; r++)
|
| 1045 |
+
for (let c = 0; c < 4; c++)
|
| 1046 |
+
ctx.fillRect(x-w/2+4+c*12, y-h/2+5+r*12, 8, 8);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
function drawHospital(x, y, dk) {
|
| 1050 |
+
const w = 50, h = 40;
|
| 1051 |
+
// Main building
|
| 1052 |
+
ctx.fillStyle = dk ? '#1e2228' : '#c8ccd0';
|
| 1053 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 1054 |
+
// Flat roof
|
| 1055 |
+
ctx.fillStyle = dk ? '#141820' : '#a0a4a8';
|
| 1056 |
+
ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
|
| 1057 |
+
// Red cross
|
| 1058 |
+
ctx.fillStyle = '#e94560';
|
| 1059 |
+
ctx.fillRect(x-3, y-h/2+4, 6, 14);
|
| 1060 |
+
ctx.fillRect(x-7, y-h/2+8, 14, 6);
|
| 1061 |
+
// Door
|
| 1062 |
+
ctx.fillStyle = dk ? '#0a1018' : '#4a5a6a';
|
| 1063 |
+
ctx.fillRect(x-5, y+h/2-12, 10, 12);
|
| 1064 |
+
// Windows
|
| 1065 |
+
const wc = dk ? 'rgba(200,240,255,0.55)' : 'rgba(200,230,255,0.5)';
|
| 1066 |
+
ctx.fillStyle = wc;
|
| 1067 |
+
for (let r = 0; r < 2; r++)
|
| 1068 |
+
for (let c = 0; c < 4; c++)
|
| 1069 |
+
ctx.fillRect(x-w/2+4+c*12, y-h/2+20+r*8, 8, 5);
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
function drawChurch(x, y, dk) {
|
| 1073 |
+
const w = 40, h = 36;
|
| 1074 |
+
// Main building
|
| 1075 |
+
ctx.fillStyle = dk ? '#2a2828' : '#c0b8a8';
|
| 1076 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 1077 |
+
// Steeple
|
| 1078 |
+
ctx.fillStyle = dk ? '#1a1818' : '#908880';
|
| 1079 |
+
ctx.fillRect(x-6, y-h/2-18, 12, 18);
|
| 1080 |
+
// Steeple top
|
| 1081 |
+
ctx.beginPath();
|
| 1082 |
+
ctx.moveTo(x-8, y-h/2-18);
|
| 1083 |
+
ctx.lineTo(x, y-h/2-30);
|
| 1084 |
+
ctx.lineTo(x+8, y-h/2-18);
|
| 1085 |
+
ctx.closePath(); ctx.fill();
|
| 1086 |
+
// Cross
|
| 1087 |
+
ctx.fillStyle = dk ? '#888' : '#d4c8a0';
|
| 1088 |
+
ctx.fillRect(x-1.5, y-h/2-38, 3, 10);
|
| 1089 |
+
ctx.fillRect(x-4, y-h/2-36, 8, 3);
|
| 1090 |
+
// Stained glass window
|
| 1091 |
+
ctx.fillStyle = dk ? 'rgba(100,150,255,0.5)' : 'rgba(80,120,200,0.4)';
|
| 1092 |
+
ctx.beginPath(); ctx.arc(x, y-h/2-8, 4, 0, 6.28); ctx.fill();
|
| 1093 |
+
// Door (arched)
|
| 1094 |
+
ctx.fillStyle = dk ? '#1a1518' : '#5a4a3a';
|
| 1095 |
+
ctx.fillRect(x-5, y+h/2-14, 10, 14);
|
| 1096 |
+
ctx.beginPath(); ctx.arc(x, y+h/2-14, 5, Math.PI, 0); ctx.fill();
|
| 1097 |
+
// Side windows
|
| 1098 |
+
const wc = dk ? 'rgba(255,200,100,0.5)' : 'rgba(200,180,120,0.45)';
|
| 1099 |
+
ctx.fillStyle = wc;
|
| 1100 |
+
ctx.fillRect(x-w/2+4, y-h/2+6, 6, 10);
|
| 1101 |
+
ctx.fillRect(x+w/2-10, y-h/2+6, 6, 10);
|
| 1102 |
+
// Label
|
| 1103 |
+
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 1104 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText("St. Mary's", x+1, y+h/2+3);
|
| 1105 |
+
ctx.fillStyle = dk ? '#a0a8c0' : '#fff'; ctx.fillText("St. Mary's", x, y+h/2+2);
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
function drawCinema(x, y, dk) {
|
| 1109 |
+
const w = 48, h = 34;
|
| 1110 |
+
// Main building
|
| 1111 |
+
ctx.fillStyle = dk ? '#2a1828' : '#5a3060';
|
| 1112 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 1113 |
+
// Marquee sign
|
| 1114 |
+
ctx.fillStyle = dk ? '#4a2040' : '#8a4080';
|
| 1115 |
+
ctx.fillRect(x-w/2+4, y-h/2-8, w-8, 10);
|
| 1116 |
+
// Marquee lights
|
| 1117 |
+
ctx.fillStyle = dk ? '#f0c040' : '#ffe880';
|
| 1118 |
+
for (let i = 0; i < 8; i++) {
|
| 1119 |
+
const lx = x-w/2+8+i*5;
|
| 1120 |
+
const flicker = Math.sin(animFrame*0.1+i*0.8) > 0;
|
| 1121 |
+
if (flicker || !dk) {
|
| 1122 |
+
ctx.beginPath(); ctx.arc(lx, y-h/2-3, 1.5, 0, 6.28); ctx.fill();
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
// "CINEMA" text
|
| 1126 |
+
ctx.fillStyle = dk ? '#f0c040' : '#fff';
|
| 1127 |
+
ctx.font = 'bold 7px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 1128 |
+
ctx.fillText('CINEMA', x, y-h/2-2);
|
| 1129 |
+
// Door
|
| 1130 |
+
ctx.fillStyle = dk ? '#1a0818' : '#3a1830';
|
| 1131 |
+
ctx.fillRect(x-5, y+h/2-12, 10, 12);
|
| 1132 |
+
// Poster frames
|
| 1133 |
+
const wc = dk ? 'rgba(255,200,100,0.4)' : 'rgba(200,180,240,0.5)';
|
| 1134 |
+
ctx.fillStyle = wc;
|
| 1135 |
+
ctx.fillRect(x-w/2+4, y-h/2+8, 12, 16);
|
| 1136 |
+
ctx.fillRect(x+w/2-16, y-h/2+8, 12, 16);
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
function drawApartment(x, y, dk) {
|
| 1140 |
+
const w = 38, h = 48;
|
| 1141 |
+
// Main building (tall)
|
| 1142 |
+
ctx.fillStyle = dk ? '#2a2830' : '#a09890';
|
| 1143 |
+
ctx.fillRect(x-w/2, y-h/2, w, h);
|
| 1144 |
+
// Flat roof
|
| 1145 |
+
ctx.fillStyle = dk ? '#1a1820' : '#807870';
|
| 1146 |
+
ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
|
| 1147 |
+
// Door
|
| 1148 |
+
ctx.fillStyle = dk ? '#1a1520' : '#605850';
|
| 1149 |
+
ctx.fillRect(x-4, y+h/2-10, 8, 10);
|
| 1150 |
+
// Windows (4 rows x 3 cols)
|
| 1151 |
+
const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(180,220,255,0.45)';
|
| 1152 |
+
ctx.fillStyle = wc;
|
| 1153 |
+
for (let r = 0; r < 4; r++)
|
| 1154 |
+
for (let c = 0; c < 3; c++) {
|
| 1155 |
+
// Some windows dark at night
|
| 1156 |
+
if (dk && ((r*3+c+animFrame) % 7 < 2)) continue;
|
| 1157 |
+
ctx.fillRect(x-w/2+4+c*12, y-h/2+5+r*11, 8, 7);
|
| 1158 |
+
}
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
function drawSquare(x, y, dk) {
|
| 1162 |
+
// Cobblestone plaza
|
| 1163 |
+
ctx.fillStyle = dk ? '#2a2820' : '#b0a890';
|
| 1164 |
+
ctx.beginPath(); ctx.ellipse(x, y, 50, 30, 0, 0, 6.28); ctx.fill();
|
| 1165 |
+
ctx.strokeStyle = dk ? '#3a3828' : '#c0b898';
|
| 1166 |
+
ctx.lineWidth = 2; ctx.stroke();
|
| 1167 |
+
// Fountain
|
| 1168 |
+
ctx.fillStyle = dk ? '#1a2a3a' : '#708898';
|
| 1169 |
+
ctx.beginPath(); ctx.ellipse(x, y, 12, 7, 0, 0, 6.28); ctx.fill();
|
| 1170 |
+
ctx.fillStyle = dk ? '#2a3a5a' : '#88b0d0';
|
| 1171 |
+
ctx.beginPath(); ctx.ellipse(x, y-2, 6, 4, 0, 0, 6.28); ctx.fill();
|
| 1172 |
+
// Water spray
|
| 1173 |
+
if (!dk || animFrame % 3 < 2) {
|
| 1174 |
+
ctx.fillStyle = `rgba(100,180,220,${dk?0.3:0.5})`;
|
| 1175 |
+
ctx.beginPath(); ctx.arc(x, y-8+Math.sin(animFrame*0.06)*2, 3, 0, 6.28); ctx.fill();
|
| 1176 |
+
}
|
| 1177 |
+
// Benches
|
| 1178 |
+
ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30';
|
| 1179 |
+
ctx.fillRect(x-30, y+12, 12, 3);
|
| 1180 |
+
ctx.fillRect(x+18, y+12, 12, 3);
|
| 1181 |
+
// Notice board
|
| 1182 |
+
ctx.fillStyle = dk ? '#2a2520' : '#8a7a60';
|
| 1183 |
+
ctx.fillRect(x+30, y-8, 8, 12);
|
| 1184 |
+
// Label
|
| 1185 |
+
ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 1186 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Town Square', x+1, y+21);
|
| 1187 |
+
ctx.fillStyle = dk ? '#a0a8b0' : '#fff'; ctx.fillText('Town Square', x, y+20);
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
function drawSportsField(x, y, dk) {
|
| 1191 |
+
// Large green field
|
| 1192 |
+
ctx.fillStyle = dk ? '#1a3018' : '#4a9a38';
|
| 1193 |
+
ctx.beginPath(); ctx.ellipse(x, y, 48, 24, 0, 0, 6.28); ctx.fill();
|
| 1194 |
+
// Field lines
|
| 1195 |
+
ctx.strokeStyle = dk ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)';
|
| 1196 |
+
ctx.lineWidth = 1.5;
|
| 1197 |
+
ctx.beginPath(); ctx.ellipse(x, y, 44, 20, 0, 0, 6.28); ctx.stroke();
|
| 1198 |
+
ctx.beginPath(); ctx.moveTo(x, y-20); ctx.lineTo(x, y+20); ctx.stroke();
|
| 1199 |
+
ctx.beginPath(); ctx.arc(x, y, 8, 0, 6.28); ctx.stroke();
|
| 1200 |
+
// Goals
|
| 1201 |
+
ctx.strokeStyle = dk ? '#555' : '#ddd'; ctx.lineWidth = 2;
|
| 1202 |
+
ctx.strokeRect(x-46, y-6, 4, 12);
|
| 1203 |
+
ctx.strokeRect(x+42, y-6, 4, 12);
|
| 1204 |
+
// Running track
|
| 1205 |
+
ctx.strokeStyle = dk ? '#3a2828' : '#c87850';
|
| 1206 |
+
ctx.lineWidth = 3;
|
| 1207 |
+
ctx.beginPath(); ctx.ellipse(x, y, 52, 28, 0, 0, 6.28); ctx.stroke();
|
| 1208 |
+
// Bleachers
|
| 1209 |
+
ctx.fillStyle = dk ? '#2a2828' : '#888';
|
| 1210 |
+
ctx.fillRect(x-20, y+26, 40, 5);
|
| 1211 |
+
// Label
|
| 1212 |
+
ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 1213 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Sports Field', x+1, y+33);
|
| 1214 |
+
ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Sports Field', x, y+32);
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
function dim(hex, f) {
|
| 1218 |
if (hex.startsWith('rgb')) return hex; // already rgb
|
| 1219 |
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
|
|
|
|
| 1286 |
// ============================================================
|
| 1287 |
function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
|
| 1288 |
const loc = agent.location || 'house_elena';
|
| 1289 |
+
const allPos = getEffectivePositions();
|
| 1290 |
+
const pos = allPos[loc];
|
| 1291 |
if (!pos) return;
|
| 1292 |
|
| 1293 |
const atLoc = byLoc[loc] || [];
|
|
|
|
| 1296 |
|
| 1297 |
// For streets, spread agents along the road
|
| 1298 |
if (pos.type === 'street') {
|
| 1299 |
+
const row = Math.floor(localIdx / 10);
|
| 1300 |
+
const col = localIdx % 10;
|
| 1301 |
const spread = Math.min(count, 10);
|
| 1302 |
+
const step = 0.025;
|
| 1303 |
const startX = pos.x - (spread-1)*step/2;
|
| 1304 |
agentTargets[id] = {
|
| 1305 |
+
x: (startX + col * step) * W,
|
| 1306 |
+
y: pos.y * H + 16 + row * 18 + (col % 2) * 10
|
| 1307 |
};
|
| 1308 |
} else {
|
| 1309 |
+
// Multi-row arrangement for large crowds
|
| 1310 |
+
const baseRadius = pos.type === 'house' ? 16 : (pos.type === 'park' || pos.type === 'square' || pos.type === 'sports' ? 35 : 24);
|
| 1311 |
+
const maxPerRow = pos.type === 'house' ? 4 : 8;
|
| 1312 |
+
const row = Math.floor(localIdx / maxPerRow);
|
| 1313 |
+
const colIdx = localIdx % maxPerRow;
|
| 1314 |
+
const rowCount = Math.min(count - row * maxPerRow, maxPerRow);
|
| 1315 |
+
const radius = baseRadius + row * 14;
|
| 1316 |
+
const step = Math.PI / Math.max(rowCount + 1, 2);
|
| 1317 |
+
const angle = step * (colIdx + 1);
|
| 1318 |
+
const ox = Math.cos(angle) * radius - radius / 3;
|
| 1319 |
+
const oy = Math.sin(angle) * radius * 0.45 + (pos.type === 'house' ? 20 : 28) + row * 6;
|
| 1320 |
+
|
| 1321 |
+
agentTargets[id] = { x: pos.x * W + ox, y: pos.y * H + oy };
|
| 1322 |
}
|
| 1323 |
if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
|
| 1324 |
}
|
|
|
|
| 1441 |
// INTERACTION
|
| 1442 |
// ============================================================
|
| 1443 |
function onCanvasClick(e) {
|
| 1444 |
+
if (isDragging) return;
|
| 1445 |
const rect=canvas.getBoundingClientRect();
|
| 1446 |
+
const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
|
| 1447 |
let clicked=null, minD=24;
|
| 1448 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1449 |
const d=Math.hypot(mx-pos.x,my-pos.y);
|
|
|
|
| 1461 |
|
| 1462 |
function onCanvasMouseMove(e) {
|
| 1463 |
const rect=canvas.getBoundingClientRect();
|
| 1464 |
+
const mx=e.clientX-rect.left+panX, my=e.clientY-rect.top+panY;
|
| 1465 |
+
const W=worldW(), H=worldH();
|
| 1466 |
const tt=document.getElementById('tooltip');
|
| 1467 |
|
| 1468 |
+
if (isDragging) return;
|
| 1469 |
+
|
| 1470 |
let foundAgent=null;
|
| 1471 |
for (const [id,pos] of Object.entries(agentPositions)) {
|
| 1472 |
if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
|
|
|
|
| 1474 |
|
| 1475 |
let foundLoc=null;
|
| 1476 |
if (!foundAgent) {
|
| 1477 |
+
const allPos = getEffectivePositions();
|
| 1478 |
+
for (const [id, pos] of Object.entries(allPos)) {
|
| 1479 |
const lx=pos.x*W, ly=pos.y*H;
|
| 1480 |
const hitR = pos.type === 'house' ? 25 : 35;
|
| 1481 |
if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}
|