Compare commits

...

12 Commits

Author SHA1 Message Date
admin d6520c426a feat: resource consumption rate display in village panel
Village panel now shows daily consumption rates alongside stockpiles (e.g. Food: 15 (-2/d), Wood: 7 (-1/d)). Rates are computed over a rolling 200-tick (~1 day) window by summing item_consumed economic events. The rate display only appears when non-zero, keeping the panel clean until actual consumption occurs. Closes the feedback loop: events show individual transfers, rates show aggregate trends.
2026-07-09 01:18:33 +02:00
admin 6981c7e9bc feat: simulation speed control with bracket keys
Press [ to slow down, ] to speed up through 6 levels: 0.25x, 0.5x, 1x, 2x, 4x, 10x. Clock delta is multiplied by the current speed, so time_of_day advances proportionally and the visual day-night cycle stays synced. Current speed shown in the village panel header. 10x makes a full day cycle watchable in ~24 seconds. Speed changes are non-destructive to simulation determinism.
2026-07-09 00:25:57 +02:00
admin 4b5fc858f8 feat: NPC mourning behavior on housemate death
When an NPC dies from starvation, their highest-familiarity companion enters mourning for 100 ticks (~half a day). Mourning NPCs skip work: they rest if energy is low, otherwise wander near home. Mourning_ticks counts down each tick through ActionExecutionSystem and is serialized for save/load continuity with backward-compatible default of 0. This gives the familiarity graph its first behavioral consequence.
2026-07-09 00:23:00 +02:00
admin 8a955f3c29 feat: profession-based staggered daily schedules
ProfessionDefinition gains schedule_offset (-0.12 to +0.12 fraction of day). ActionSelectionSystem shifts each NPC's effective time_of_day by their profession offset before determining sleep/meal/work periods. Farmers wake earliest (-0.04h = ~2:38am), woodcutters slightly early (-0.02h), guards slightly late (+0.02h), scholars latest (+0.05h = ~5:45am). Wanderers have no offset. The village now has naturally staggered rhythms instead of everyone doing the same thing at the same time.
2026-07-09 00:19:14 +02:00
admin 2c3890502a feat: wood consumption for patrol and study
Patrol and study now consume 1 wood from the village woodpile on completion, recording an item_consumed economic event. This gives wood a purpose and creates resource pressure between gathering and work-support activities. Familiarity serialization switched from raw Dictionary to sorted Array of {id, score} pairs for deterministic JSON checksum output across save/load.
2026-07-09 00:16:52 +02:00
admin 595d67724f feat: runtime familiarity from shared work and utility exposure
NPCs who target the same resource or site concurrently gain mutual familiarity +0.1 per encounter, capped at 1.0. Action selection now receives the full NPC array and adds a +0.3 utility bonus for actions that familiar NPCs are currently performing - NPCs gravitate toward their housemates' professions. Inspector shows the highest-familiarity NPC with a percentage score under the resource lines.
2026-07-09 00:09:50 +02:00
admin fce5c533fe feat: seed household familiarity from shared home proximity
NPCs whose home positions are within 4m of each other gain mutual familiarity at baseline 0.5. Stored per NPC as a Dictionary keyed by other npc_id, serialized through NPCStateRecord with backward-compatible fallback for old saves. Household pairs form naturally from the home_positions configuration in main.tscn. Includes save/load round-trip test verifying familiarity persists.
2026-07-09 00:03:20 +02:00
admin 8094975094 feat: resource depletion events and village event feed
Record resource_depleted narrative events when NPC or player extraction exhausts a ResourceNode. Add get_recent_events() for cross-NPC event queries. Village panel now shows the last 3 village-wide events as a readable feed below the resource counters, making shortages and depletion visible without opening the inspector.
2026-07-08 21:22:43 +02:00
admin 8a1913c5ae feat: structured narrative event feed with per-NPC inspector timeline
Add narrative event types (task_started, npc_slept, npc_died, resource_depleted) alongside economic transfers. EconomicEventRecord gains create_narrative factory and human-readable description method. SimulationManager records task-start and sleep/death events, exposes get_npc_events() for per-NPC history queries. Inspector now shows a Recent timeline of the last 5 events for the selected NPC, plus displays carried wood count. Relax EconomicEventRecord validation to accept non-economic events. Update roadmap with completed milestones.
2026-07-08 19:02:11 +02:00
admin c61d286005 feat: wood inventory, carrying, and woodpile storage
Wood now follows the same gather-carry-deposit pattern as food. NPCs put harvested wood into inventory, walk to the VillageWoodpile StorageNode, and deposit it. Village wood syncs from storage. Player harvesting also routes wood to the woodpile. ActiveWorldAdapter exposes woodpile routing for DEPOSIT_WOOD action targeting. WorldViewManager recognizes wood inventory changes. Tests verify woodpile node existence, storage ID, navigation reachability, and adapter routing.
2026-07-08 18:55:51 +02:00
admin 5ea83b06ed feat: directional wind breeze and painted-terrain Ghibli styling
- Wind strokes now flow in a single prevailing direction (left-to-right breeze)
  with gentle flatness, narrow spread, and subtle tangential sway
- Added floating white specks (WindSpecks) as sunlight pollen/dust motes
- Two of each emitter (valley + ridge) for layered atmosphere
- Terrain: disabled auto_base_texture for flatter painted look, enabled
  overlay texture, bolder macro-variation (yellow-green 0.78/0.90/0.42
  and emerald 0.55/0.75/0.38), sharper material blends
2026-07-08 18:49:23 +02:00
admin ae0b3dbf89 chore: add wind stroke shader uid sidecar 2026-07-08 18:47:47 +02:00
28 changed files with 667 additions and 78 deletions
+26 -8
View File
@@ -652,14 +652,14 @@ Completed after the architecture gate:
The practical next sequence is:
1. Continue growing resource discovery with finite `ResourceNode` instances
placed in meaningful foliage, animal-camp, berry, and village-stockpile
contexts. The first distance/safety/comfort scoring pass, twelve authored
resources, and placement validation exist; next additions should increase
authored variety only when they preserve reachability, stable IDs, scoring
metadata, and clear player interaction ranges.
2. Expand persistence only when schedules, player state, or a real save menu
creates a concrete requirement.
1. Expand the structured event feed with actor/target/cause metadata so the
inspector can show a readable timeline per NPC. Narrative events for task
starts, sleep, and death are recorded; next should add depletion notices,
relationship seeds, and village-level event summaries filtered by relevance.
2. Seed relationship dimensions (familiarity, trust, obligation) from shared
work, meal, and sleep proximity, then expose them through utility
considerations so NPCs begin to prefer known colleagues and familiar routes.
Start with one lightweight dimension before building the full graph.
Recently completed:
@@ -693,6 +693,24 @@ Recently completed:
multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river
and waterfall; fortress with crenellations, three turrets, and prominent
ridge banner.
- Resource discovery expanded to 18 finite ResourceNodes across farming,
deep-forest, river-bank, and mill-adjacent contexts with scored metadata.
- NPC schedules implemented: SLEEP/MEAL/WORK/DISCRETIONARY periods driven by
simulation clock time-of-day. DayNightCycle synced to simulation clock. NPCs
walk home to designated house positions at night, sleep to restore energy,
eat at meal times, and work during the day. Starving NPCs always override
sleep to seek food.
- Starvation balance reworked: hunger ~25/day, death threshold ~3 days of
starvation (realistic ~6-7 day survival without food). Energy drain uses
exact binary fractions for lossless JSON serialization.
- Wood inventory and woodpile storage: wood now follows the same
gather→carry→deposit pattern as food. VillageWoodpile StorageNode receives
wood deposits. Village wood syncs from storage. Economic events track all
transfers.
- Structured event feed: EconomicEventRecord now supports narrative events
(task started, NPC slept, NPC died) alongside economic transfers.
Per-NPC event history with human-readable descriptions exposed in the
inspector under a "Recent" timeline.
This order strengthens the simulation while regularly producing visible
progress suitable for public development updates.
+2 -1
View File
@@ -59,9 +59,10 @@ max_zoom_offset = Vector3(0, 20, 15)
current = true
fov = 65.0
[node name="ActiveWorldAdapter" type="Node" parent="." unique_id=1773902505 node_paths=PackedStringArray("pantry_storage")]
[node name="ActiveWorldAdapter" type="Node" parent="." unique_id=1773902505 node_paths=PackedStringArray("pantry_storage", "woodpile_storage")]
script = ExtResource("9_adapter")
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
woodpile_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillageWoodpile")
[node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")]
script = ExtResource("3_lquwl")
+2
View File
@@ -32,6 +32,8 @@ var has_travel_target := false
var inventory: Dictionary = {}
var last_task: StringName
var random_source: RandomNumberGenerator
var familiarity: Dictionary = {}
var mourning_ticks := 0
var debug_logs := true
+241 -8
View File
@@ -31,6 +31,9 @@ var latest_decisions: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
var speed_index := 2
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
signal speed_changed(multiplier: float)
var names := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
@@ -58,11 +61,26 @@ func _ready() -> void:
func _process(delta: float) -> void:
var ticks_due := clock.advance(delta)
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index])
for tick in ticks_due:
simulate_tick()
func _input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode == KEY_BRACKETLEFT:
speed_index = maxi(speed_index - 1, 0)
speed_changed.emit(SPEED_LEVELS[speed_index])
if debug_logs:
print("[SimulationManager] Speed: %.2fx" % SPEED_LEVELS[speed_index])
elif event.keycode == KEY_BRACKETRIGHT:
speed_index = mini(speed_index + 1, SPEED_LEVELS.size() - 1)
speed_changed.emit(SPEED_LEVELS[speed_index])
if debug_logs:
print("[SimulationManager] Speed: %.2fx" % SPEED_LEVELS[speed_index])
func generate_npcs() -> void:
var profession_ids := SimulationDefinitions.get_profession_ids()
for i in range(names.size()):
@@ -86,6 +104,12 @@ func generate_npcs() -> void:
for i in range(npcs.size()):
npcs[i].home_position = home_positions[i % home_count]
for i in range(npcs.size()):
for j in range(i + 1, npcs.size()):
if npcs[i].home_position.distance_to(npcs[j].home_position) < 4.0:
npcs[i].familiarity[npcs[j].id] = 0.5
npcs[j].familiarity[npcs[i].id] = 0.5
func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
var source := RandomNumberGenerator.new()
@@ -121,11 +145,18 @@ func simulate_tick() -> void:
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
):
var selection := action_selector.select_action(npc, village, clock.time_of_day())
var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs)
if selection != null:
latest_decisions[npc.id] = selection
npc_decision_recorded.emit(npc, selection)
npc.set_task(selection.action_id, selection.duration_override)
var action_def := SimulationDefinitions.get_action(selection.action_id)
var action_display := String(selection.action_id)
if action_def != null:
action_display = action_def.display_name
record_narrative_event(
SimulationIds.EVENT_TASK_STARTED, npc.id, &"", action_display
)
if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id)
@@ -135,6 +166,8 @@ func simulate_tick() -> void:
release_npc_reservation(npc.id)
npc_died.emit(npc)
npc_task_changed.emit(npc, old_task, npc.current_task)
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
_notify_mourning(npc)
if debug_logs:
print("[SimulationManager] NPC died: ", npc.npc_name)
@@ -162,6 +195,13 @@ func simulate_tick() -> void:
SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
npc.add_inventory(SimulationIds.RESOURCE_WOOD, extracted)
npc_inventory_changed.emit(
npc,
SimulationIds.RESOURCE_WOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
)
else:
village.apply_resource_delta(
resource_state.get_resource_id(), extracted
@@ -172,12 +212,20 @@ func simulate_tick() -> void:
resource_state.get_node_id(),
(
_npc_inventory_id(npc.id)
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
if resource_state.get_resource_id() in [
SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD
]
else &"village"
),
resource_state.get_resource_id(),
extracted
)
if resource_state.get_amount_remaining() <= 0.0:
record_narrative_event(
SimulationIds.EVENT_RESOURCE_DEPLETED,
npc.id,
resource_state.get_node_id()
)
if debug_logs:
print(
"[SimulationManager] ",
@@ -200,6 +248,8 @@ func simulate_tick() -> void:
release_npc_reservation(npc.id)
elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD:
deposit_npc_inventory(npc, SimulationIds.RESOURCE_FOOD)
elif completed_task == SimulationIds.ACTION_DEPOSIT_WOOD:
deposit_npc_wood(npc)
elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD:
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
elif completed_task == SimulationIds.ACTION_EAT:
@@ -207,13 +257,25 @@ func simulate_tick() -> void:
elif completed_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
if debug_logs:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
elif (
completed_task
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
not in [
SimulationIds.ACTION_GATHER_FOOD,
SimulationIds.ACTION_GATHER_WOOD,
SimulationIds.ACTION_PATROL,
SimulationIds.ACTION_STUDY
]
):
village.apply_npc_task(npc)
elif completed_task == SimulationIds.ACTION_PATROL:
_consume_wood_for_work(npc, completed_task)
village.apply_npc_task(npc)
elif completed_task == SimulationIds.ACTION_STUDY:
_consume_wood_for_work(npc, completed_task)
village.apply_npc_task(npc)
elif debug_logs:
print(
"[SimulationManager] ",
@@ -328,6 +390,23 @@ func notify_npc_arrived(npc_id: int) -> void:
npc.has_travel_target = false
npc.start_working()
if npc.target_id != &"":
for other in npcs:
if other.id == npc.id or other.is_dead:
continue
if (
other.target_id == npc.target_id
and other.task_state in [
SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING
]
):
npc.familiarity[other.id] = minf(
float(npc.familiarity.get(other.id, 0.0)) + 0.1, 1.0
)
other.familiarity[npc.id] = minf(
float(other.familiarity.get(npc.id, 0.0)) + 0.1, 1.0
)
if debug_logs:
print(
"[SimulationManager] ",
@@ -431,6 +510,30 @@ func _npc_inventory_id(npc_id: int) -> StringName:
return StringName("npc_%d_inventory" % npc_id)
func _notify_mourning(dead_npc: SimNPC) -> void:
var best_id := -1
var best_score := -1.0
for key in dead_npc.familiarity:
var other_id: int = int(key)
var score: float = float(dead_npc.familiarity[key])
if score > best_score:
best_score = score
best_id = other_id
if best_id < 0:
return
for npc in npcs:
if npc.id == best_id and not npc.is_dead:
npc.mourning_ticks = 100
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" is mourning ",
dead_npc.npc_name
)
return
func _record_economic_event(
event_type: StringName,
actor_id: int,
@@ -449,20 +552,89 @@ func _record_economic_event(
economic_event_recorded.emit(event)
func record_narrative_event(
event_type: StringName,
actor_id: int,
source_id: StringName = &"",
action_display: String = ""
) -> void:
var event := EconomicEventRecord.create_narrative(
next_event_id, event_type, tick_count, actor_id, source_id, action_display
)
next_event_id += 1
economic_events.append(event)
economic_event_recorded.emit(event)
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
var results: Array[EconomicEventRecord] = []
for i in range(economic_events.size() - 1, -1, -1):
var event: EconomicEventRecord = economic_events[i]
if event.data["actor_id"] == npc_id:
results.append(event)
if results.size() >= max_count:
break
results.reverse()
return results
func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
var results: Array[EconomicEventRecord] = []
var start := maxi(economic_events.size() - max_count, 0)
for i in range(start, economic_events.size()):
results.append(economic_events[i])
return results
func get_current_speed() -> String:
return "%.2fx" % SPEED_LEVELS[speed_index]
func get_resource_rates() -> Dictionary:
var food_consumed := 0.0
var wood_consumed := 0.0
var recent_ticks := maxi(tick_count - 200, 0)
for event in economic_events:
if int(event.data["tick"]) < recent_ticks:
continue
if String(event.data["event_type"]) != "item_consumed":
continue
if float(event.data["amount"]) <= 0.0:
continue
if String(event.data["item_id"]) == "food":
food_consumed += float(event.data["amount"])
elif String(event.data["item_id"]) == "wood":
wood_consumed += float(event.data["amount"])
var elapsed := maxi(tick_count - recent_ticks, 1)
return {
"food_per_day": food_consumed / elapsed * 200.0,
"wood_per_day": wood_consumed / elapsed * 200.0
}
func _initialize_storage() -> void:
if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
_sync_village_food()
return
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_FOOD): village.food}
SimulationIds.STORAGE_VILLAGE_PANTRY,
{String(SimulationIds.RESOURCE_FOOD): village.food}
))
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = (StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_WOODPILE,
{String(SimulationIds.RESOURCE_WOOD): village.wood}
))
_sync_village_food()
_sync_village_wood()
func get_pantry() -> StorageStateRecord:
return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord
func get_woodpile() -> StorageStateRecord:
return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord
func register_loaded_storage_nodes() -> void:
for candidate in StorageNode.get_all():
var node := candidate as StorageNode
@@ -490,6 +662,14 @@ func _sync_village_food() -> void:
village.update_priorities()
func _sync_village_wood() -> void:
var woodpile := get_woodpile()
if woodpile != null:
village.wood = woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
village.update_modifiers()
village.update_priorities()
func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
var available := npc.get_inventory_amount(item_id)
var deposited := get_pantry().deposit(item_id, available)
@@ -508,6 +688,48 @@ func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
return deposited
func deposit_npc_wood(npc: SimNPC) -> float:
var available := npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
var woodpile := get_woodpile()
if woodpile == null:
return 0.0
var deposited := woodpile.deposit(SimulationIds.RESOURCE_WOOD, available)
npc.remove_inventory(SimulationIds.RESOURCE_WOOD, deposited)
_sync_village_wood()
if deposited > 0.0:
npc_inventory_changed.emit(
npc, SimulationIds.RESOURCE_WOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
)
_record_economic_event(
SimulationIds.EVENT_STORAGE_DEPOSITED,
npc.id,
_npc_inventory_id(npc.id),
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD,
deposited
)
return deposited
func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> void:
var woodpile := get_woodpile()
if woodpile == null:
return
var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0)
_sync_village_wood()
if consumed >= 1.0:
_record_economic_event(
SimulationIds.EVENT_ITEM_CONSUMED,
npc.id,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
&"consumed",
SimulationIds.RESOURCE_WOOD,
consumed
)
if debug_logs:
print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id])
func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float:
var withdrawn := get_pantry().withdraw(item_id, amount)
npc.add_inventory(item_id, withdrawn)
@@ -620,6 +842,9 @@ func harvest_resource_node(node: ResourceNode) -> float:
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted)
_sync_village_food()
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
get_woodpile().deposit(SimulationIds.RESOURCE_WOOD, extracted)
_sync_village_wood()
else:
village.apply_resource_delta(resource_state.get_resource_id(), extracted)
_record_economic_event(
@@ -629,11 +854,19 @@ func harvest_resource_node(node: ResourceNode) -> float:
(
SimulationIds.STORAGE_VILLAGE_PANTRY
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
else (
SimulationIds.STORAGE_VILLAGE_WOODPILE
if resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD
else &"village"
)
),
resource_state.get_resource_id(),
extracted
)
if resource_state.get_amount_remaining() <= 0.0:
record_narrative_event(
SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id()
)
village_changed.emit(village)
if debug_logs:
@@ -6,6 +6,8 @@ func advance_npc(npc: SimNPC, village: SimVillage) -> void:
if npc.is_dead:
return
_update_needs(npc, village)
if npc.mourning_ticks > 0:
npc.mourning_ticks -= 1
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING:
return
npc.task_progress += 1.0
+29 -4
View File
@@ -18,7 +18,7 @@ const WORK_BEGIN := 0.28
const WORK_END := 0.85
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -> ActionSelectionResult:
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []) -> ActionSelectionResult:
if npc.is_dead:
return null
@@ -34,6 +34,14 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Starving; must find food"
)
if npc.mourning_ticks > 0:
if npc.energy < 40.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Mourning; seeking rest"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_WANDER, -1.0, "Mourning; wandering near home"
)
if npc.hunger > 80.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
@@ -47,7 +55,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Critically hungry; must find food"
)
var period := _get_period(time_of_day)
var profession_def := SimulationDefinitions.get_profession(npc.profession)
var offset: float = profession_def.schedule_offset if profession_def != null else 0.0
var local_time := fmod(time_of_day + offset + 1.0, 1.0)
var period := _get_period(local_time)
if period == SchedulePeriod.SLEEP:
if npc.energy < 60.0:
@@ -104,6 +115,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
)
if npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_WOOD, -1.0, "Carrying wood for storage"
)
if npc.energy < 25.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
@@ -116,10 +131,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
)
return _choose_best_work_action(npc, village)
return _choose_best_work_action(npc, village, all_npcs)
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) -> ActionSelectionResult:
var scores := {
SimulationIds.ACTION_GATHER_FOOD:
_calculate_score(
@@ -166,6 +181,16 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelecti
0.75
)
}
for familiar_id in npc.familiarity:
if not familiar_id is int:
continue
var other_task: StringName
for other in all_npcs:
if other.id == familiar_id and not other.is_dead:
other_task = other.current_task
break
if other_task in scores:
scores[other_task] = float(scores[other_task]) + 0.3
var best_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action]
for action_id in [
@@ -5,6 +5,7 @@ extends Resource
@export var display_name: String
@export var visual_color := Color.WHITE
@export var prop_color := Color(0.35, 0.25, 0.15, 1.0)
@export_range(-0.12, 0.12, 0.005) var schedule_offset := 0.0
func validate() -> Array[String]:
@@ -11,7 +11,8 @@ const ACTION_PATHS := [
"res://simulation/definitions/actions/wander.tres",
"res://simulation/definitions/actions/deposit_food.tres",
"res://simulation/definitions/actions/withdraw_food.tres",
"res://simulation/definitions/actions/sleep.tres"
"res://simulation/definitions/actions/sleep.tres",
"res://simulation/definitions/actions/deposit_wood.tres"
]
const PROFESSION_PATHS := [
+6
View File
@@ -13,6 +13,7 @@ const ACTION_REST := &"rest"
const ACTION_WANDER := &"wander"
const ACTION_DEPOSIT_FOOD := &"deposit_food"
const ACTION_WITHDRAW_FOOD := &"withdraw_food"
const ACTION_DEPOSIT_WOOD := &"deposit_wood"
const PROFESSION_FARMER := &"farmer"
const PROFESSION_WOODCUTTER := &"woodcutter"
@@ -28,8 +29,13 @@ const RESOURCE_FOOD := &"food"
const RESOURCE_WOOD := &"wood"
const STORAGE_VILLAGE_PANTRY := &"village_pantry"
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
const EVENT_RESOURCE_EXTRACTED := &"resource_extracted"
const EVENT_STORAGE_DEPOSITED := &"storage_deposited"
const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn"
const EVENT_ITEM_CONSUMED := &"item_consumed"
const EVENT_NPC_SLEPT := &"npc_slept"
const EVENT_NPC_DIED := &"npc_died"
const EVENT_TASK_STARTED := &"task_started"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
@@ -0,0 +1,10 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
action_id = &"deposit_wood"
display_name = "Deposit Wood"
default_duration = 1.0
target_type = &"activity"
@@ -8,3 +8,4 @@ profession_id = &"farmer"
display_name = "Farmer"
visual_color = Color(0.36, 0.62, 0.28, 1)
prop_color = Color(0.65, 0.45, 0.2, 1)
schedule_offset = -0.04
@@ -8,3 +8,4 @@ profession_id = &"guard"
display_name = "Guard"
visual_color = Color(0.25, 0.42, 0.68, 1)
prop_color = Color(0.72, 0.75, 0.78, 1)
schedule_offset = 0.02
@@ -8,3 +8,4 @@ profession_id = &"scholar"
display_name = "Scholar"
visual_color = Color(0.52, 0.34, 0.68, 1)
prop_color = Color(0.78, 0.66, 0.28, 1)
schedule_offset = 0.05
@@ -8,3 +8,4 @@ profession_id = &"woodcutter"
display_name = "Woodcutter"
visual_color = Color(0.67, 0.34, 0.2, 1)
prop_color = Color(0.34, 0.22, 0.14, 1)
schedule_offset = -0.02
+53 -4
View File
@@ -35,6 +35,30 @@ static func create(
)
static func create_narrative(
event_id: int,
event_type: StringName,
tick: int,
actor_id: int,
source_id: StringName,
action_display: String = ""
) -> EconomicEventRecord:
return EconomicEventRecord.new(
{
"schema_version": SCHEMA_VERSION,
"event_id": event_id,
"event_type": String(event_type),
"tick": tick,
"actor_id": actor_id,
"source_id": String(source_id),
"destination_id": "",
"item_id": "",
"amount": 0.0,
"action_name": action_display
}
)
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
@@ -65,10 +89,6 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
normalized["event_id"] < 0
or normalized["tick"] < 0
or normalized["event_type"].is_empty()
or normalized["source_id"].is_empty()
or normalized["destination_id"].is_empty()
or normalized["item_id"].is_empty()
or normalized["amount"] <= 0.0
):
return null
return EconomicEventRecord.new(normalized)
@@ -76,3 +96,32 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
func to_dictionary() -> Dictionary:
return data.duplicate(true)
func description(npc_names: Dictionary = {}) -> String:
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
var item: String = str(data["item_id"])
var amount: float = float(data["amount"])
var event_type: String = str(data["event_type"])
var source: String = str(data["source_id"])
var destination: String = str(data["destination_id"])
var action_name: String = str(data.get("action_name", ""))
match event_type:
"resource_extracted":
return "%s gathered %.0f %s from %s" % [actor_name, amount, item, source]
"storage_deposited":
return "%s deposited %.0f %s into %s" % [actor_name, amount, item, destination]
"storage_withdrawn":
return "%s withdrew %.0f %s from %s" % [actor_name, amount, item, source]
"item_consumed":
return "%s consumed %.0f %s" % [actor_name, amount, item]
"npc_slept":
return "%s slept and recovered energy" % actor_name
"npc_died":
return "%s died from starvation" % actor_name
"task_started":
return "%s began %s" % [actor_name, action_name if not action_name.is_empty() else item]
"resource_depleted":
return "%s was depleted" % source
_:
return "tick %d: %s" % [int(data["tick"]), event_type]
+23 -1
View File
@@ -45,7 +45,9 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"inventory": npc.inventory.duplicate(true),
"last_task": String(npc.last_task),
"random_seed": str(npc.random_source.seed),
"random_state": str(npc.random_source.state)
"random_state": str(npc.random_source.state),
"familiarity": _sorted_familiarity(npc.familiarity),
"mourning_ticks": npc.mourning_ticks
}
)
@@ -160,8 +162,28 @@ func restore(debug_logs: bool) -> SimNPC:
npc.last_task = StringName(data["last_task"])
npc.random_source.state = String(data["random_state"]).to_int()
npc.debug_logs = debug_logs
var saved_familiarity = data.get("familiarity", {})
if saved_familiarity is Array:
for pair in saved_familiarity:
var pair_dict: Dictionary = pair
npc.familiarity[int(pair_dict["id"])] = float(pair_dict["score"])
elif saved_familiarity is Dictionary:
npc.familiarity = saved_familiarity.duplicate(true)
npc.mourning_ticks = int(data.get("mourning_ticks", 0))
return npc
func to_dictionary() -> Dictionary:
return data.duplicate(true)
static func _sorted_familiarity(familiarity: Dictionary) -> Array:
var pairs: Array[Dictionary] = []
for key in familiarity:
pairs.append({"id": int(key), "score": float(familiarity[key])})
pairs.sort_custom(_familiarity_sort)
return pairs
static func _familiarity_sort(a: Dictionary, b: Dictionary) -> bool:
return int(a["id"]) < int(b["id"])
+12 -8
View File
@@ -150,17 +150,21 @@ func _check_economic_event_chain(manager: Node) -> void:
SimulationIds.EVENT_STORAGE_WITHDRAWN,
SimulationIds.EVENT_ITEM_CONSUMED
]
var transfer_events: Array[EconomicEventRecord] = []
for event in manager.economic_events:
if float(event.data["amount"]) > 0.0:
transfer_events.append(event)
_check(
manager.economic_events.size() == expected_types.size(),
transfer_events.size() == expected_types.size(),
"Food transfer loop should emit one structured event per transfer"
)
if manager.economic_events.size() != expected_types.size():
if transfer_events.size() != expected_types.size():
return
for index in expected_types.size():
var event: EconomicEventRecord = manager.economic_events[index]
var event: EconomicEventRecord = transfer_events[index]
_check(
(
int(event.data["event_id"]) == index
int(event.data["event_id"]) >= 0
and StringName(event.data["event_type"]) == expected_types[index]
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
),
@@ -168,10 +172,10 @@ func _check_economic_event_chain(manager: Node) -> void:
)
_check(
(
is_equal_approx(float(manager.economic_events[0].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[1].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[2].data["amount"]), 1.0)
and is_equal_approx(float(manager.economic_events[3].data["amount"]), 1.0)
is_equal_approx(float(transfer_events[0].data["amount"]), 2.0)
and is_equal_approx(float(transfer_events[1].data["amount"]), 2.0)
and is_equal_approx(float(transfer_events[2].data["amount"]), 1.0)
and is_equal_approx(float(transfer_events[3].data["amount"]), 1.0)
),
"Economic events should record exact transferred quantities"
)
+11
View File
@@ -106,6 +106,12 @@ func _run() -> void:
)
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
destinations.append(pantry.get_interaction_position())
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
"Runtime should expose a typed VillageWoodpile StorageNode"
)
var woodpile := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
destinations.append(woodpile.get_interaction_position())
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
for site in activity_root.get_children():
@@ -149,6 +155,11 @@ func _run() -> void:
storage_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_PANTRY),
"Runtime adapter should route storage actions to village_pantry"
)
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
_check(
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
"Runtime adapter should route wood deposit to village_woodpile"
)
var study_target := adapter.get_activity_target(SimulationIds.ACTION_STUDY)
_check(
study_target.get("target_id", "") == "study_desk",
+13
View File
@@ -187,6 +187,12 @@ func _run() -> void:
pantry.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY,
"VillagePantry should preserve the stable storage ID"
)
var woodpile := world.get_node("WorldObjects/StorageSites/VillageWoodpile") as StorageNode
_check(woodpile != null, "JajceWorld should include a typed VillageWoodpile StorageNode")
_check(
woodpile.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE,
"VillageWoodpile should preserve the stable storage ID"
)
_check(
not world.has_node("LegacyActivityMarkers/PantryMarker"),
"Pantry should no longer be a legacy activity marker"
@@ -224,6 +230,7 @@ func _run() -> void:
for resource in resources:
destinations.append((resource as ResourceNode).interaction_point.global_position)
destinations.append(pantry.get_interaction_position())
destinations.append(woodpile.get_interaction_position())
for site in activity_root.get_children():
destinations.append((site as ActivitySite).get_interaction_position())
@@ -243,6 +250,7 @@ func _run() -> void:
var adapter := ActiveWorldAdapter.new()
adapter.pantry_storage = pantry
adapter.woodpile_storage = woodpile
root.add_child(adapter)
var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD)
_check(food_candidates.size() >= 9, "Adapter should expose all authored food candidates")
@@ -296,6 +304,11 @@ func _run() -> void:
storage_target_position.distance_to(pantry.get_interaction_position()) < 0.01,
"Storage actions should use the pantry StorageNode interaction point"
)
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
_check(
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
"Wood deposit should target the typed woodpile's stable ID"
)
var rest_target := adapter.get_activity_target(SimulationIds.ACTION_REST)
_check(
rest_target.get("target_id", "") == "rest_bench",
+60
View File
@@ -14,6 +14,7 @@ func _run() -> void:
_test_sleep_restores_energy()
_test_schedule_serialization()
_test_sleep_targets_home()
_test_household_familiarity()
if failures.is_empty():
print("[TEST] NPC schedule passed")
@@ -68,6 +69,7 @@ func _test_work_period_during_day() -> void:
"100 ticks should land in the work period"
)
var npc: SimNPC = manager.npcs[0]
npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 20.0
npc.energy = 80.0
npc.home_position = npc.position
@@ -93,6 +95,7 @@ func _test_meal_period_hunger() -> void:
"50 ticks should land in the breakfast meal period"
)
var npc: SimNPC = manager.npcs[0]
npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 65.0
npc.energy = 80.0
manager.village.food = 5.0
@@ -181,6 +184,63 @@ func _test_sleep_targets_home() -> void:
manager.free()
func _test_household_familiarity() -> void:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = 700
manager.debug_logs = false
manager.set_process(false)
manager.home_positions = [
Vector3(-15.0, 2.0, 7.0),
Vector3(-15.0, 2.0, 8.0),
Vector3(-8.5, 2.0, 3.0),
Vector3(-6.5, 2.0, 1.5),
Vector3(20.0, 0.0, 20.0),
Vector3(22.0, 0.0, 22.0)
]
root.add_child(manager)
var a: SimNPC = manager.npcs[0]
var b: SimNPC = manager.npcs[1]
var c: SimNPC = manager.npcs[2]
var d: SimNPC = manager.npcs[3]
var e: SimNPC = manager.npcs[4]
_check(
a.familiarity.has(b.id) and b.familiarity.has(a.id),
"NPCs with nearby homes should be mutual household members"
)
_check(
is_equal_approx(float(a.familiarity[b.id]), 0.5),
"Household familiarity should start at baseline 0.5"
)
_check(
c.familiarity.has(d.id) and d.familiarity.has(c.id),
"Second household pair should also be familiar"
)
_check(
not a.familiarity.has(e.id),
"Distant NPCs should not have household familiarity"
)
var saved_json: String = manager.serialize_state()
var restored: Node = load("res://simulation/SimulationManager.gd").new()
restored.debug_logs = false
root.add_child(restored)
restored.set_process(false)
_check(
restored.restore_state_from_json(saved_json),
"Familiarity should survive save/load round-trip"
)
var ra: SimNPC = restored.npcs[0]
_check(
ra.familiarity.size() > 0,
"Restored NPC should retain household familiarity"
)
manager.free()
restored.free()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+1 -1
View File
@@ -34,7 +34,7 @@ func _test_registry_integrity() -> void:
SimulationDefinitions.get_action(definition.action_id) == definition,
"Action lookup should return '%s'" % definition.action_id
)
_check(action_ids.size() == 10, "Registry should contain all ten executable prototype actions")
_check(action_ids.size() == 11, "Registry should contain all eleven executable prototype actions")
var profession_ids := SimulationDefinitions.get_profession_ids()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
+3
View File
@@ -2,6 +2,7 @@ class_name ActiveWorldAdapter
extends Node
@export var pantry_storage: StorageNode
@export var woodpile_storage: StorageNode
func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
@@ -38,6 +39,8 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO)
return _get_storage_target(pantry_storage)
SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD:
return _get_storage_target(pantry_storage)
SimulationIds.ACTION_DEPOSIT_WOOD:
return _get_storage_target(woodpile_storage)
return {}
+17 -8
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=40 format=3]
[gd_scene load_steps=41 format=3]
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -17,17 +17,18 @@
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"]
[ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
[ext_resource type="PackedScene" path="res://world/jajce/WindStrokes.tscn" id="19_windstrokes"]
[ext_resource type="PackedScene" path="res://world/jajce/WindSpecks.tscn" id="20_windspecks"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = {
"auto_base_texture": 1,
"auto_overlay_texture": 0,
"auto_slope": 0.68,
"blend_sharpness": 0.78,
"auto_base_texture": 0,
"auto_overlay_texture": 1,
"auto_slope": 0.62,
"blend_sharpness": 0.88,
"enable_macro_variation": true,
"macro_variation1": Color(0.84, 0.92, 0.55, 1),
"macro_variation2": Color(0.62, 0.78, 0.48, 1),
"macro_variation_slope": 0.42
"macro_variation1": Color(0.78, 0.9, 0.42, 1),
"macro_variation2": Color(0.55, 0.75, 0.38, 1),
"macro_variation_slope": 0.48
}
world_background = 0
auto_shader = true
@@ -472,6 +473,10 @@ discovery_priority = 0.45
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
position = Vector3(0, 0, -8)
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
position = Vector3(8, 0, -12)
storage_id = &"village_woodpile"
[node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-1.05, 1.1, 1.25)
mesh = SubResource("Mesh_site_pole")
@@ -606,6 +611,10 @@ directional_shadow_max_distance = 180.0
[node name="WindStrokes_Ridge" parent="." instance=ExtResource("19_windstrokes")]
[node name="WindSpecks_Valley" parent="." instance=ExtResource("20_windspecks")]
[node name="WindSpecks_Ridge" parent="." instance=ExtResource("20_windspecks")]
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_greybox")
+37
View File
@@ -0,0 +1,37 @@
[gd_scene load_steps=4 format=3]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_specks"]
lifetime_randomness = 0.8
spread = 120.0
flatness = 0.5
gravity = Vector3(-0.35, 0.25, 0.0)
initial_velocity_min = 0.2
initial_velocity_max = 1.2
scale_min = 0.06
scale_max = 0.28
color = Color(1, 0.98, 0.9, 0.3)
color_ramp = 1
emission_shape = 1
emission_box_extents = Vector3(32.0, 14.0, 32.0)
[sub_resource type="StandardMaterial3D" id="Material_speck"]
albedo_color = Color(1, 0.98, 0.92, 0.3)
transparency = 1
shading_mode = 0
billboard_mode = 1
cull_mode = 0
[sub_resource type="QuadMesh" id="Mesh_speck"]
material = SubResource("Material_speck")
size = Vector2(0.12, 0.12)
[node name="WindSpecks" type="GPUParticles3D"]
emitting = true
amount = 40
lifetime = 6.0
one_shot = false
preprocess = 4.0
speed_scale = 1.0
local_coords = false
process_material = SubResource("ParticleProcessMaterial_specks")
draw_pass_1 = SubResource("Mesh_speck")
+20 -19
View File
@@ -7,32 +7,33 @@ shader = ExtResource("1_shader")
[sub_resource type="QuadMesh" id="Mesh_stroke"]
material = SubResource("ShaderMaterial_stroke")
size = Vector2(1.8, 0.18)
size = Vector2(2.4, 0.14)
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_wind"]
lifetime_randomness = 0.5
spread = 160.0
gravity = Vector3(0, 0.15, 0)
initial_velocity_min = 0.6
initial_velocity_max = 2.5
angular_velocity_min = -0.6
angular_velocity_max = 0.6
radial_accel_min = -0.2
radial_accel_max = 0.2
tangential_accel_min = -0.3
tangential_accel_max = 0.3
scale_min = 0.5
scale_max = 1.5
color = Color(1, 1, 1, 0.5)
lifetime_randomness = 0.6
spread = 55.0
flatness = 0.7
gravity = Vector3(-0.6, 0.35, 0.0)
initial_velocity_min = 0.8
initial_velocity_max = 2.8
angular_velocity_min = -0.25
angular_velocity_max = 0.25
radial_accel_min = 0.0
radial_accel_max = 0.0
tangential_accel_min = -0.08
tangential_accel_max = 0.08
scale_min = 0.3
scale_max = 1.2
color = Color(1, 1, 1, 0.35)
emission_shape = 1
emission_box_extents = Vector3(35.0, 10.0, 35.0)
emission_box_extents = Vector3(28.0, 8.0, 28.0)
[node name="WindStrokes" type="GPUParticles3D"]
emitting = true
amount = 18
lifetime = 4.0
amount = 24
lifetime = 5.0
one_shot = false
preprocess = 2.0
preprocess = 3.0
speed_scale = 1.0
local_coords = false
process_material = SubResource("ParticleProcessMaterial_wind")
@@ -0,0 +1 @@
uid://b8dxberetmchf
+82 -6
View File
@@ -27,6 +27,8 @@ func _ready() -> void:
push_error("VillageUI: SimulationManager has no npc_decision_recorded signal")
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
if simulation_manager.has_signal("speed_changed"):
simulation_manager.speed_changed.connect(_on_speed_changed)
if "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
@@ -52,12 +54,33 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _on_village_changed(village: SimVillage) -> void:
var name_map := {}
for npc in simulation_manager.npcs:
name_map[npc.id] = npc.npc_name
var event_lines: Array[String] = []
for event in simulation_manager.get_recent_events(3):
event_lines.append(event.description(name_map))
var event_text := "\n".join(event_lines)
if event_text.is_empty():
event_text = ""
else:
event_text = "\n" + event_text
var speed_text := ""
if simulation_manager.has_method("get_current_speed"):
speed_text = simulation_manager.get_current_speed()
var rates: Dictionary = simulation_manager.get_resource_rates()
var food_rate := ""
if rates["food_per_day"] > 0.01:
food_rate = " (%.0f/d)" % rates["food_per_day"]
var wood_rate := ""
if rates["wood_per_day"] > 0.01:
wood_rate = " (%.0f/d)" % rates["wood_per_day"]
village_stats_label.text = (
"""
Village
Village [%s]
Food: %s
Wood: %s
Food: %s%s
Wood: %s%s
Safety: %s
Knowledge: %s
Starving: %s
@@ -65,16 +88,21 @@ func _on_village_changed(village: SimVillage) -> void:
Food Mod: %.2f
Safety Mod: %.2f
Knowledge Mod: %.2f
%s
"""
% [
speed_text,
round(village.food),
food_rate,
round(village.wood),
wood_rate,
round(village.safety),
round(village.knowledge),
simulation_manager.get_starving_count(),
village.food_modifier,
village.safety_modifier,
village.knowledge_modifier
village.knowledge_modifier,
event_text
]
)
@@ -88,6 +116,11 @@ func _on_state_restored() -> void:
_refresh_npc_inspector()
func _on_speed_changed(_multiplier: float) -> void:
if simulation_manager != null and "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
func _refresh_npc_inspector() -> void:
if not debug_overlay_visible:
return
@@ -118,13 +151,17 @@ func _refresh_npc_inspector() -> void:
)
if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows)
var event_text := _build_event_history(npc)
var housemate_text := _build_housemate_display(npc)
npc_inspector_label.text = (
"%s · %s\n"
+ "Task: %s (%s)\n"
+ "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f\n\n"
+ "Carrying food: %.0f wood: %.0f\n"
+ "%s\n"
+ "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
) % [
npc.npc_name,
@@ -135,8 +172,11 @@ func _refresh_npc_inspector() -> void:
npc.hunger,
npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
housemate_text,
reason_text,
score_text
score_text,
event_text
]
@@ -156,3 +196,39 @@ func set_debug_overlay_visible(is_visible: bool) -> void:
visible = is_visible
if is_visible:
_refresh_npc_inspector()
func _build_event_history(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_npc_events"):
return ""
var events: Array = simulation_manager.get_npc_events(npc.id, 5)
if events.is_empty():
return "No recorded events yet"
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
var lines: Array[String] = []
for event in events:
lines.append(event.description(name_map))
return "\n".join(lines)
func _build_housemate_display(npc: SimNPC) -> String:
if npc.familiarity.is_empty():
return ""
var highest_id: int = -1
var highest_score := -1.0
for key in npc.familiarity:
var other_id: int = int(key)
var score: float = float(npc.familiarity[key])
if score > highest_score:
highest_score = score
highest_id = other_id
if highest_id < 0:
return ""
var other_name := "Someone"
for other_npc in simulation_manager.npcs:
if other_npc.id == highest_id:
other_name = other_npc.npc_name
break
return "Familiar: %s (%.0f%%)" % [other_name, highest_score * 100.0]
+4 -4
View File
@@ -161,15 +161,15 @@ func _on_npc_died(npc: SimNPC) -> void:
func _on_npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float) -> void:
if item_id != SimulationIds.RESOURCE_FOOD:
return
var visual = active_npc_visuals.get(npc.id)
if visual == null:
return
if item_id == SimulationIds.RESOURCE_FOOD:
if visual.has_method("set_carried_food_visible"):
visual.set_carried_food_visible(amount > 0.0)
else:
push_error("WorldViewManager: NPCVisual has no set_carried_food_visible method")
elif item_id == SimulationIds.RESOURCE_WOOD:
if visual.has_method("set_carried_wood_visible"):
visual.set_carried_wood_visible(amount > 0.0)
func _on_npc_task_changed(npc: SimNPC, _old_task: StringName, _new_task: StringName) -> void: