Compare commits

...

14 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
admin 7aa5c1c979 feat: starvation balance rework and Ghibli wind-stroke visuals
- Reduce hunger rate to 0.125/tick (~25/day) for ~6-7 day death timeline
- Slow energy drain: idle 0.125, travel 0.5, work 0.5, complete 0.0625/tick
- Use exact binary fractions for all rates to preserve float precision in save/load
- Starving/critically-hungry NPCs always override sleep to seek food first
- Extend starvation death threshold from 20 to 600 ticks (~3 days of starvation)
- Add calligraphy-style wind stroke particles (WindStrokes scene + shader)
- Two wind stroke emitters in valley and ridge for Ghibli atmosphere
- Rework terrain macro-variation colors for vibrant warm-green palette
- Enhance environment: stronger glow/bloom, higher saturation, warmer sky/fog/ambient
2026-07-08 18:44:03 +02:00
admin 6008b1023e chore: update terrain assets with Godot UIDs and foliage mesh 2026-07-08 18:35:17 +02:00
31 changed files with 786 additions and 93 deletions
+26 -8
View File
@@ -652,14 +652,14 @@ Completed after the architecture gate:
The practical next sequence is: The practical next sequence is:
1. Continue growing resource discovery with finite `ResourceNode` instances 1. Expand the structured event feed with actor/target/cause metadata so the
placed in meaningful foliage, animal-camp, berry, and village-stockpile inspector can show a readable timeline per NPC. Narrative events for task
contexts. The first distance/safety/comfort scoring pass, twelve authored starts, sleep, and death are recorded; next should add depletion notices,
resources, and placement validation exist; next additions should increase relationship seeds, and village-level event summaries filtered by relevance.
authored variety only when they preserve reachability, stable IDs, scoring 2. Seed relationship dimensions (familiarity, trust, obligation) from shared
metadata, and clear player interaction ranges. work, meal, and sleep proximity, then expose them through utility
2. Expand persistence only when schedules, player state, or a real save menu considerations so NPCs begin to prefer known colleagues and familiar routes.
creates a concrete requirement. Start with one lightweight dimension before building the full graph.
Recently completed: Recently completed:
@@ -693,6 +693,24 @@ Recently completed:
multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river
and waterfall; fortress with crenellations, three turrets, and prominent and waterfall; fortress with crenellations, three turrets, and prominent
ridge banner. 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 This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+2 -1
View File
@@ -59,9 +59,10 @@ max_zoom_offset = Vector3(0, 20, 15)
current = true current = true
fov = 65.0 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") script = ExtResource("9_adapter")
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") 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")] [node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")]
script = ExtResource("3_lquwl") script = ExtResource("3_lquwl")
+3 -1
View File
@@ -20,7 +20,7 @@ var home_position: Vector3
var is_starving := false var is_starving := false
var starvation_ticks := 0 var starvation_ticks := 0
var starvation_death_threshold := 20 var starvation_death_threshold := 600
var is_dead := false var is_dead := false
var task_duration := 0.0 var task_duration := 0.0
@@ -32,6 +32,8 @@ var has_travel_target := false
var inventory: Dictionary = {} var inventory: Dictionary = {}
var last_task: StringName var last_task: StringName
var random_source: RandomNumberGenerator var random_source: RandomNumberGenerator
var familiarity: Dictionary = {}
var mourning_ticks := 0
var debug_logs := true var debug_logs := true
+241 -8
View File
@@ -31,6 +31,9 @@ var latest_decisions: Dictionary = {}
var action_selector := ActionSelectionSystem.new() var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new() var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.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"] var names := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
@@ -58,11 +61,26 @@ func _ready() -> void:
func _process(delta: float) -> 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: for tick in ticks_due:
simulate_tick() 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: func generate_npcs() -> void:
var profession_ids := SimulationDefinitions.get_profession_ids() var profession_ids := SimulationDefinitions.get_profession_ids()
for i in range(names.size()): for i in range(names.size()):
@@ -86,6 +104,12 @@ func generate_npcs() -> void:
for i in range(npcs.size()): for i in range(npcs.size()):
npcs[i].home_position = home_positions[i % home_count] 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: func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
var source := RandomNumberGenerator.new() 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 old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
and npc.task_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: if selection != null:
latest_decisions[npc.id] = selection latest_decisions[npc.id] = selection
npc_decision_recorded.emit(npc, selection) npc_decision_recorded.emit(npc, selection)
npc.set_task(selection.action_id, selection.duration_override) 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 != &"": if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -135,6 +166,8 @@ func simulate_tick() -> void:
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
npc_died.emit(npc) npc_died.emit(npc)
npc_task_changed.emit(npc, old_task, npc.current_task) 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: if debug_logs:
print("[SimulationManager] NPC died: ", npc.npc_name) print("[SimulationManager] NPC died: ", npc.npc_name)
@@ -162,6 +195,13 @@ func simulate_tick() -> void:
SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(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: else:
village.apply_resource_delta( village.apply_resource_delta(
resource_state.get_resource_id(), extracted resource_state.get_resource_id(), extracted
@@ -172,12 +212,20 @@ func simulate_tick() -> void:
resource_state.get_node_id(), resource_state.get_node_id(),
( (
_npc_inventory_id(npc.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" else &"village"
), ),
resource_state.get_resource_id(), resource_state.get_resource_id(),
extracted 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: if debug_logs:
print( print(
"[SimulationManager] ", "[SimulationManager] ",
@@ -200,6 +248,8 @@ func simulate_tick() -> void:
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD: elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD:
deposit_npc_inventory(npc, SimulationIds.RESOURCE_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: elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD:
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0) withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
elif completed_task == SimulationIds.ACTION_EAT: elif completed_task == SimulationIds.ACTION_EAT:
@@ -207,13 +257,25 @@ func simulate_tick() -> void:
elif completed_task == SimulationIds.ACTION_SLEEP: elif completed_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 40.0, 100.0) npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position npc.position = npc.home_position
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
if debug_logs: if debug_logs:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home") print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
elif ( elif (
completed_task 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) 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: elif debug_logs:
print( print(
"[SimulationManager] ", "[SimulationManager] ",
@@ -328,6 +390,23 @@ func notify_npc_arrived(npc_id: int) -> void:
npc.has_travel_target = false npc.has_travel_target = false
npc.start_working() 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: if debug_logs:
print( print(
"[SimulationManager] ", "[SimulationManager] ",
@@ -431,6 +510,30 @@ func _npc_inventory_id(npc_id: int) -> StringName:
return StringName("npc_%d_inventory" % npc_id) 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( func _record_economic_event(
event_type: StringName, event_type: StringName,
actor_id: int, actor_id: int,
@@ -449,20 +552,89 @@ func _record_economic_event(
economic_event_recorded.emit(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: func _initialize_storage() -> void:
if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
_sync_village_food()
return
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create( 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_food()
_sync_village_wood()
func get_pantry() -> StorageStateRecord: func get_pantry() -> StorageStateRecord:
return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as 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: func register_loaded_storage_nodes() -> void:
for candidate in StorageNode.get_all(): for candidate in StorageNode.get_all():
var node := candidate as StorageNode var node := candidate as StorageNode
@@ -490,6 +662,14 @@ func _sync_village_food() -> void:
village.update_priorities() 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: func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
var available := npc.get_inventory_amount(item_id) var available := npc.get_inventory_amount(item_id)
var deposited := get_pantry().deposit(item_id, available) var deposited := get_pantry().deposit(item_id, available)
@@ -508,6 +688,48 @@ func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
return deposited 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: func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float:
var withdrawn := get_pantry().withdraw(item_id, amount) var withdrawn := get_pantry().withdraw(item_id, amount)
npc.add_inventory(item_id, withdrawn) 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: if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted) get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted)
_sync_village_food() _sync_village_food()
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
get_woodpile().deposit(SimulationIds.RESOURCE_WOOD, extracted)
_sync_village_wood()
else: else:
village.apply_resource_delta(resource_state.get_resource_id(), extracted) village.apply_resource_delta(resource_state.get_resource_id(), extracted)
_record_economic_event( _record_economic_event(
@@ -629,11 +854,19 @@ func harvest_resource_node(node: ResourceNode) -> float:
( (
SimulationIds.STORAGE_VILLAGE_PANTRY SimulationIds.STORAGE_VILLAGE_PANTRY
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD 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" else &"village"
)
), ),
resource_state.get_resource_id(), resource_state.get_resource_id(),
extracted 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) village_changed.emit(village)
if debug_logs: if debug_logs:
+11 -7
View File
@@ -6,6 +6,8 @@ func advance_npc(npc: SimNPC, village: SimVillage) -> void:
if npc.is_dead: if npc.is_dead:
return return
_update_needs(npc, village) _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: if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING:
return return
npc.task_progress += 1.0 npc.task_progress += 1.0
@@ -15,19 +17,21 @@ func advance_npc(npc: SimNPC, village: SimVillage) -> void:
func _update_needs(npc: SimNPC, village: SimVillage) -> void: func _update_needs(npc: SimNPC, village: SimVillage) -> void:
npc.hunger += 3.0 * village.food_modifier npc.hunger += 0.125 * village.food_modifier
match npc.task_state: match npc.task_state:
SimNPC.TASK_STATE_IDLE: SimNPC.TASK_STATE_IDLE:
npc.energy -= 0.5 npc.energy -= 0.125
SimNPC.TASK_STATE_TRAVELING: SimNPC.TASK_STATE_TRAVELING:
npc.energy -= 2.0 npc.energy -= 0.5
SimNPC.TASK_STATE_WORKING: SimNPC.TASK_STATE_WORKING:
if npc.current_task == SimulationIds.ACTION_SLEEP: if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 5.0, 100.0) npc.energy = minf(npc.energy + 1.0, 100.0)
elif npc.current_task != SimulationIds.ACTION_REST: elif npc.current_task == SimulationIds.ACTION_REST:
npc.energy -= 3.0 pass
else:
npc.energy -= 0.5
SimNPC.TASK_STATE_COMPLETE: SimNPC.TASK_STATE_COMPLETE:
npc.energy -= 0.25 npc.energy -= 0.0625
npc.hunger = clamp(npc.hunger, 0.0, 100.0) npc.hunger = clamp(npc.hunger, 0.0, 100.0)
npc.energy = clamp(npc.energy, 0.0, 100.0) npc.energy = clamp(npc.energy, 0.0, 100.0)
npc.is_starving = npc.hunger >= 90.0 npc.is_starving = npc.hunger >= 90.0
+54 -4
View File
@@ -18,11 +18,47 @@ const WORK_BEGIN := 0.28
const WORK_END := 0.85 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: if npc.is_dead:
return null return null
var period := _get_period(time_of_day) if npc.is_starving:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
)
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(
SimulationIds.ACTION_EAT, -1.0, "Critically hungry and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Critically hungry; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Critically hungry; must find food"
)
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 period == SchedulePeriod.SLEEP:
if npc.energy < 60.0: if npc.energy < 60.0:
@@ -79,6 +115,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage" 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: if npc.energy < 25.0:
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
@@ -91,10 +131,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander" 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 := { var scores := {
SimulationIds.ACTION_GATHER_FOOD: SimulationIds.ACTION_GATHER_FOOD:
_calculate_score( _calculate_score(
@@ -141,6 +181,16 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelecti
0.75 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_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action] var best_score: float = scores[best_action]
for action_id in [ for action_id in [
@@ -5,6 +5,7 @@ extends Resource
@export var display_name: String @export var display_name: String
@export var visual_color := Color.WHITE @export var visual_color := Color.WHITE
@export var prop_color := Color(0.35, 0.25, 0.15, 1.0) @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]: func validate() -> Array[String]:
@@ -11,7 +11,8 @@ const ACTION_PATHS := [
"res://simulation/definitions/actions/wander.tres", "res://simulation/definitions/actions/wander.tres",
"res://simulation/definitions/actions/deposit_food.tres", "res://simulation/definitions/actions/deposit_food.tres",
"res://simulation/definitions/actions/withdraw_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 := [ const PROFESSION_PATHS := [
+6
View File
@@ -13,6 +13,7 @@ const ACTION_REST := &"rest"
const ACTION_WANDER := &"wander" const ACTION_WANDER := &"wander"
const ACTION_DEPOSIT_FOOD := &"deposit_food" const ACTION_DEPOSIT_FOOD := &"deposit_food"
const ACTION_WITHDRAW_FOOD := &"withdraw_food" const ACTION_WITHDRAW_FOOD := &"withdraw_food"
const ACTION_DEPOSIT_WOOD := &"deposit_wood"
const PROFESSION_FARMER := &"farmer" const PROFESSION_FARMER := &"farmer"
const PROFESSION_WOODCUTTER := &"woodcutter" const PROFESSION_WOODCUTTER := &"woodcutter"
@@ -28,8 +29,13 @@ const RESOURCE_FOOD := &"food"
const RESOURCE_WOOD := &"wood" const RESOURCE_WOOD := &"wood"
const STORAGE_VILLAGE_PANTRY := &"village_pantry" const STORAGE_VILLAGE_PANTRY := &"village_pantry"
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
const EVENT_RESOURCE_EXTRACTED := &"resource_extracted" const EVENT_RESOURCE_EXTRACTED := &"resource_extracted"
const EVENT_STORAGE_DEPOSITED := &"storage_deposited" const EVENT_STORAGE_DEPOSITED := &"storage_deposited"
const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn" const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn"
const EVENT_ITEM_CONSUMED := &"item_consumed" 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" display_name = "Farmer"
visual_color = Color(0.36, 0.62, 0.28, 1) visual_color = Color(0.36, 0.62, 0.28, 1)
prop_color = Color(0.65, 0.45, 0.2, 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" display_name = "Guard"
visual_color = Color(0.25, 0.42, 0.68, 1) visual_color = Color(0.25, 0.42, 0.68, 1)
prop_color = Color(0.72, 0.75, 0.78, 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" display_name = "Scholar"
visual_color = Color(0.52, 0.34, 0.68, 1) visual_color = Color(0.52, 0.34, 0.68, 1)
prop_color = Color(0.78, 0.66, 0.28, 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" display_name = "Woodcutter"
visual_color = Color(0.67, 0.34, 0.2, 1) visual_color = Color(0.67, 0.34, 0.2, 1)
prop_color = Color(0.34, 0.22, 0.14, 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: static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null return null
@@ -65,10 +89,6 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
normalized["event_id"] < 0 normalized["event_id"] < 0
or normalized["tick"] < 0 or normalized["tick"] < 0
or normalized["event_type"].is_empty() 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 null
return EconomicEventRecord.new(normalized) return EconomicEventRecord.new(normalized)
@@ -76,3 +96,32 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
return data.duplicate(true) 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), "inventory": npc.inventory.duplicate(true),
"last_task": String(npc.last_task), "last_task": String(npc.last_task),
"random_seed": str(npc.random_source.seed), "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.last_task = StringName(data["last_task"])
npc.random_source.state = String(data["random_state"]).to_int() npc.random_source.state = String(data["random_state"]).to_int()
npc.debug_logs = debug_logs 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 return npc
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
return data.duplicate(true) 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"])
+27 -9
View File
@@ -1,16 +1,33 @@
[gd_resource type="Terrain3DAssets" load_steps=14 format=3] [gd_resource type="Terrain3DAssets" format=3 uid="uid://bdv3tfrg08t8d"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/rock_albedo.png" id="1_rock"] [ext_resource type="Texture2D" uid="uid://cf16txygbbyqb" path="res://terrain/jajce/textures/rock_albedo.png" id="1_rock"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/grass_albedo.png" id="2_grass"] [ext_resource type="Texture2D" uid="uid://d0ihxulegsl2w" path="res://terrain/jajce/textures/grass_albedo.png" id="2_grass"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/soil_albedo.png" id="3_soil"] [ext_resource type="Texture2D" uid="uid://b75t0ego2sdja" path="res://terrain/jajce/textures/soil_albedo.png" id="3_soil"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/dirt_path_albedo.png" id="4_dirt"] [ext_resource type="Texture2D" uid="uid://ca7nmo3t6rhtj" path="res://terrain/jajce/textures/dirt_path_albedo.png" id="4_dirt"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/riverbank_stone_albedo.png" id="5_river"] [ext_resource type="Texture2D" uid="uid://bsl74sdmhj270" path="res://terrain/jajce/textures/riverbank_stone_albedo.png" id="5_river"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/compacted_ground_albedo.png" id="6_compact"] [ext_resource type="Texture2D" uid="uid://e0ahjmsbpagw" path="res://terrain/jajce/textures/compacted_ground_albedo.png" id="6_compact"]
[ext_resource type="Texture2D" path="res://terrain/jajce/textures/neutral_normal.png" id="7_normal"] [ext_resource type="Texture2D" uid="uid://bjcbxamqe7yji" path="res://terrain/jajce/textures/neutral_normal.png" id="7_normal"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_whuny"]
transparency = 4
cull_mode = 2
vertex_color_use_as_albedo = true
backlight_enabled = true
backlight = Color(0.5, 0.5, 0.5, 1)
distance_fade_mode = 1
distance_fade_min_distance = 128.0
distance_fade_max_distance = 96.0
[sub_resource type="Terrain3DMeshAsset" id="Terrain3DMeshAsset_2g08h"]
generated_type = 1
height_offset = 0.5
material_override = SubResource("StandardMaterial3D_whuny")
last_lod = 0
last_shadow_lod = 0
lod0_range = 128.0
[sub_resource type="Terrain3DTextureAsset" id="Texture_rock"] [sub_resource type="Terrain3DTextureAsset" id="Texture_rock"]
name = "Limestone" name = "Limestone"
id = 0
albedo_texture = ExtResource("1_rock") albedo_texture = ExtResource("1_rock")
normal_texture = ExtResource("7_normal") normal_texture = ExtResource("7_normal")
normal_depth = 0.0 normal_depth = 0.0
@@ -63,4 +80,5 @@ uv_scale = 0.2
detiling_rotation = 0.08 detiling_rotation = 0.08
[resource] [resource]
mesh_list = Array[Terrain3DMeshAsset]([SubResource("Terrain3DMeshAsset_2g08h")])
texture_list = Array[Terrain3DTextureAsset]([SubResource("Texture_rock"), SubResource("Texture_grass"), SubResource("Texture_soil"), SubResource("Texture_dirt"), SubResource("Texture_river"), SubResource("Texture_compact")]) texture_list = Array[Terrain3DTextureAsset]([SubResource("Texture_rock"), SubResource("Texture_grass"), SubResource("Texture_soil"), SubResource("Texture_dirt"), SubResource("Texture_river"), SubResource("Texture_compact")])
+12 -8
View File
@@ -150,17 +150,21 @@ func _check_economic_event_chain(manager: Node) -> void:
SimulationIds.EVENT_STORAGE_WITHDRAWN, SimulationIds.EVENT_STORAGE_WITHDRAWN,
SimulationIds.EVENT_ITEM_CONSUMED 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( _check(
manager.economic_events.size() == expected_types.size(), transfer_events.size() == expected_types.size(),
"Food transfer loop should emit one structured event per transfer" "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 return
for index in expected_types.size(): for index in expected_types.size():
var event: EconomicEventRecord = manager.economic_events[index] var event: EconomicEventRecord = transfer_events[index]
_check( _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["event_type"]) == expected_types[index]
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
), ),
@@ -168,10 +172,10 @@ func _check_economic_event_chain(manager: Node) -> void:
) )
_check( _check(
( (
is_equal_approx(float(manager.economic_events[0].data["amount"]), 2.0) is_equal_approx(float(transfer_events[0].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[1].data["amount"]), 2.0) and is_equal_approx(float(transfer_events[1].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[2].data["amount"]), 1.0) and is_equal_approx(float(transfer_events[2].data["amount"]), 1.0)
and is_equal_approx(float(manager.economic_events[3].data["amount"]), 1.0) and is_equal_approx(float(transfer_events[3].data["amount"]), 1.0)
), ),
"Economic events should record exact transferred quantities" "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 var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
destinations.append(pantry.get_interaction_position()) 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") var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites") _check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
for site in activity_root.get_children(): for site in activity_root.get_children():
@@ -149,6 +155,11 @@ func _run() -> void:
storage_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_PANTRY), storage_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_PANTRY),
"Runtime adapter should route storage actions to 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) var study_target := adapter.get_activity_target(SimulationIds.ACTION_STUDY)
_check( _check(
study_target.get("target_id", "") == "study_desk", study_target.get("target_id", "") == "study_desk",
+13
View File
@@ -187,6 +187,12 @@ func _run() -> void:
pantry.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY, pantry.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY,
"VillagePantry should preserve the stable storage ID" "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( _check(
not world.has_node("LegacyActivityMarkers/PantryMarker"), not world.has_node("LegacyActivityMarkers/PantryMarker"),
"Pantry should no longer be a legacy activity marker" "Pantry should no longer be a legacy activity marker"
@@ -224,6 +230,7 @@ func _run() -> void:
for resource in resources: for resource in resources:
destinations.append((resource as ResourceNode).interaction_point.global_position) destinations.append((resource as ResourceNode).interaction_point.global_position)
destinations.append(pantry.get_interaction_position()) destinations.append(pantry.get_interaction_position())
destinations.append(woodpile.get_interaction_position())
for site in activity_root.get_children(): for site in activity_root.get_children():
destinations.append((site as ActivitySite).get_interaction_position()) destinations.append((site as ActivitySite).get_interaction_position())
@@ -243,6 +250,7 @@ func _run() -> void:
var adapter := ActiveWorldAdapter.new() var adapter := ActiveWorldAdapter.new()
adapter.pantry_storage = pantry adapter.pantry_storage = pantry
adapter.woodpile_storage = woodpile
root.add_child(adapter) root.add_child(adapter)
var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD) var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD)
_check(food_candidates.size() >= 9, "Adapter should expose all authored food candidates") _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_target_position.distance_to(pantry.get_interaction_position()) < 0.01,
"Storage actions should use the pantry StorageNode interaction point" "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) var rest_target := adapter.get_activity_target(SimulationIds.ACTION_REST)
_check( _check(
rest_target.get("target_id", "") == "rest_bench", rest_target.get("target_id", "") == "rest_bench",
+60
View File
@@ -14,6 +14,7 @@ func _run() -> void:
_test_sleep_restores_energy() _test_sleep_restores_energy()
_test_schedule_serialization() _test_schedule_serialization()
_test_sleep_targets_home() _test_sleep_targets_home()
_test_household_familiarity()
if failures.is_empty(): if failures.is_empty():
print("[TEST] NPC schedule passed") print("[TEST] NPC schedule passed")
@@ -68,6 +69,7 @@ func _test_work_period_during_day() -> void:
"100 ticks should land in the work period" "100 ticks should land in the work period"
) )
var npc: SimNPC = manager.npcs[0] var npc: SimNPC = manager.npcs[0]
npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 20.0 npc.hunger = 20.0
npc.energy = 80.0 npc.energy = 80.0
npc.home_position = npc.position npc.home_position = npc.position
@@ -93,6 +95,7 @@ func _test_meal_period_hunger() -> void:
"50 ticks should land in the breakfast meal period" "50 ticks should land in the breakfast meal period"
) )
var npc: SimNPC = manager.npcs[0] var npc: SimNPC = manager.npcs[0]
npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 65.0 npc.hunger = 65.0
npc.energy = 80.0 npc.energy = 80.0
manager.village.food = 5.0 manager.village.food = 5.0
@@ -181,6 +184,63 @@ func _test_sleep_targets_home() -> void:
manager.free() 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: func _check(condition: bool, message: String) -> void:
if not condition: if not condition:
failures.append(message) failures.append(message)
+1
View File
@@ -0,0 +1 @@
uid://bqv1xjvihivns
+1 -1
View File
@@ -34,7 +34,7 @@ func _test_registry_integrity() -> void:
SimulationDefinitions.get_action(definition.action_id) == definition, SimulationDefinitions.get_action(definition.action_id) == definition,
"Action lookup should return '%s'" % definition.action_id "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() var profession_ids := SimulationDefinitions.get_profession_ids()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions") _check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
+3
View File
@@ -2,6 +2,7 @@ class_name ActiveWorldAdapter
extends Node extends Node
@export var pantry_storage: StorageNode @export var pantry_storage: StorageNode
@export var woodpile_storage: StorageNode
func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: 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) return _get_storage_target(pantry_storage)
SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD: SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD:
return _get_storage_target(pantry_storage) return _get_storage_target(pantry_storage)
SimulationIds.ACTION_DEPOSIT_WOOD:
return _get_storage_target(woodpile_storage)
return {} return {}
+39 -25
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=39 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="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -16,17 +16,19 @@
[ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"] [ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"]
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"] [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="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"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
"auto_base_texture": 1, "auto_base_texture": 0,
"auto_overlay_texture": 0, "auto_overlay_texture": 1,
"auto_slope": 0.72, "auto_slope": 0.62,
"blend_sharpness": 0.82, "blend_sharpness": 0.88,
"enable_macro_variation": true, "enable_macro_variation": true,
"macro_variation1": Color(0.9, 0.94, 0.82, 1), "macro_variation1": Color(0.78, 0.9, 0.42, 1),
"macro_variation2": Color(0.76, 0.81, 0.7, 1), "macro_variation2": Color(0.55, 0.75, 0.38, 1),
"macro_variation_slope": 0.38 "macro_variation_slope": 0.48
} }
world_background = 0 world_background = 0
auto_shader = true auto_shader = true
@@ -96,10 +98,10 @@ material = SubResource("Material_site_canvas")
size = Vector3(2.9, 0.08, 1.4) size = Vector3(2.9, 0.08, 1.4)
[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"] [sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"]
sky_top_color = Color(0.23, 0.43, 0.62, 1) sky_top_color = Color(0.28, 0.52, 0.72, 1)
sky_horizon_color = Color(0.82, 0.68, 0.5, 1) sky_horizon_color = Color(0.88, 0.74, 0.52, 1)
ground_bottom_color = Color(0.12, 0.17, 0.12, 1) ground_bottom_color = Color(0.18, 0.24, 0.14, 1)
ground_horizon_color = Color(0.62, 0.55, 0.42, 1) ground_horizon_color = Color(0.68, 0.6, 0.44, 1)
sun_angle_max = 18.0 sun_angle_max = 18.0
[sub_resource type="Sky" id="Sky_jajce"] [sub_resource type="Sky" id="Sky_jajce"]
@@ -110,18 +112,18 @@ background_mode = 2
sky = SubResource("Sky_jajce") sky = SubResource("Sky_jajce")
background_energy_multiplier = 0.9 background_energy_multiplier = 0.9
ambient_light_source = 3 ambient_light_source = 3
ambient_light_color = Color(0.91, 0.85, 0.72, 1) ambient_light_color = Color(0.95, 0.9, 0.76, 1)
ambient_light_energy = 0.7 ambient_light_energy = 0.75
reflected_light_source = 2 reflected_light_source = 2
tonemap_mode = 2 tonemap_mode = 2
glow_enabled = true glow_enabled = true
glow_normalized = true glow_normalized = true
glow_intensity = 0.34 glow_intensity = 0.5
glow_strength = 0.75 glow_strength = 0.9
glow_bloom = 0.08 glow_bloom = 0.12
fog_enabled = true fog_enabled = true
fog_light_color = Color(0.82, 0.78, 0.66, 1) fog_light_color = Color(0.88, 0.84, 0.7, 1)
fog_light_energy = 0.72 fog_light_energy = 0.78
fog_density = 0.002 fog_density = 0.002
fog_height = -2.0 fog_height = -2.0
fog_height_density = 0.08 fog_height_density = 0.08
@@ -129,15 +131,15 @@ fog_aerial_perspective = 0.35
fog_sky_affect = 0.28 fog_sky_affect = 0.28
volumetric_fog_enabled = true volumetric_fog_enabled = true
volumetric_fog_density = 0.006 volumetric_fog_density = 0.006
volumetric_fog_albedo = Color(0.9, 0.84, 0.72, 1) volumetric_fog_albedo = Color(0.94, 0.88, 0.75, 1)
volumetric_fog_emission = Color(0.08, 0.065, 0.04, 1) volumetric_fog_emission = Color(0.1, 0.07, 0.04, 1)
volumetric_fog_emission_energy = 0.18 volumetric_fog_emission_energy = 0.22
volumetric_fog_length = 96.0 volumetric_fog_length = 96.0
volumetric_fog_detail_spread = 1.6 volumetric_fog_detail_spread = 1.6
adjustment_enabled = true adjustment_enabled = true
adjustment_brightness = 1.04 adjustment_brightness = 1.08
adjustment_contrast = 1.03 adjustment_contrast = 1.04
adjustment_saturation = 1.1 adjustment_saturation = 1.25
[sub_resource type="StandardMaterial3D" id="Material_guard"] [sub_resource type="StandardMaterial3D" id="Material_guard"]
albedo_color = Color(0.55, 0.45, 0.35, 1) albedo_color = Color(0.55, 0.45, 0.35, 1)
@@ -471,6 +473,10 @@ discovery_priority = 0.45
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] [node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
position = Vector3(0, 0, -8) 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"] [node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-1.05, 1.1, 1.25) position = Vector3(-1.05, 1.1, 1.25)
mesh = SubResource("Mesh_site_pole") mesh = SubResource("Mesh_site_pole")
@@ -601,6 +607,14 @@ light_energy = 1.45
shadow_enabled = true shadow_enabled = true
directional_shadow_max_distance = 180.0 directional_shadow_max_distance = 180.0
[node name="WindStrokes_Valley" parent="." instance=ExtResource("19_windstrokes")]
[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="."] [node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_greybox") 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")
+40
View File
@@ -0,0 +1,40 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Shader" path="res://world/jajce/materials/wind_stroke.gdshader" id="1_shader"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_stroke"]
shader = ExtResource("1_shader")
[sub_resource type="QuadMesh" id="Mesh_stroke"]
material = SubResource("ShaderMaterial_stroke")
size = Vector2(2.4, 0.14)
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_wind"]
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(28.0, 8.0, 28.0)
[node name="WindStrokes" type="GPUParticles3D"]
emitting = true
amount = 24
lifetime = 5.0
one_shot = false
preprocess = 3.0
speed_scale = 1.0
local_coords = false
process_material = SubResource("ParticleProcessMaterial_wind")
draw_pass_1 = SubResource("Mesh_stroke")
@@ -0,0 +1,14 @@
shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_disabled, unshaded;
uniform float softness : hint_range(0.0, 1.0) = 0.45;
uniform vec3 tint : source_color = vec3(0.95, 0.93, 0.88);
void fragment() {
float edge_dist = abs(UV.y - 0.5) * 2.0;
float alpha = 1.0 - smoothstep(0.0, 1.0, edge_dist);
alpha *= smoothstep(0.0, 0.2, UV.x) * smoothstep(0.0, 0.2, 1.0 - UV.x);
alpha *= softness * 0.35;
ALBEDO = tint;
ALPHA = alpha;
}
@@ -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") push_error("VillageUI: SimulationManager has no npc_decision_recorded signal")
if simulation_manager.has_signal("state_restored"): if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_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: if "village" in simulation_manager:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
@@ -52,12 +54,33 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _on_village_changed(village: SimVillage) -> 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_stats_label.text = (
""" """
Village Village [%s]
Food: %s Food: %s%s
Wood: %s Wood: %s%s
Safety: %s Safety: %s
Knowledge: %s Knowledge: %s
Starving: %s Starving: %s
@@ -65,16 +88,21 @@ func _on_village_changed(village: SimVillage) -> void:
Food Mod: %.2f Food Mod: %.2f
Safety Mod: %.2f Safety Mod: %.2f
Knowledge Mod: %.2f Knowledge Mod: %.2f
%s
""" """
% [ % [
speed_text,
round(village.food), round(village.food),
food_rate,
round(village.wood), round(village.wood),
wood_rate,
round(village.safety), round(village.safety),
round(village.knowledge), round(village.knowledge),
simulation_manager.get_starving_count(), simulation_manager.get_starving_count(),
village.food_modifier, village.food_modifier,
village.safety_modifier, village.safety_modifier,
village.knowledge_modifier village.knowledge_modifier,
event_text
] ]
) )
@@ -88,6 +116,11 @@ func _on_state_restored() -> void:
_refresh_npc_inspector() _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: func _refresh_npc_inspector() -> void:
if not debug_overlay_visible: if not debug_overlay_visible:
return return
@@ -118,13 +151,17 @@ func _refresh_npc_inspector() -> void:
) )
if not score_rows.is_empty(): if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows) 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 = ( npc_inspector_label.text = (
"%s · %s\n" "%s · %s\n"
+ "Task: %s (%s)\n" + "Task: %s (%s)\n"
+ "Destination: %s\n" + "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\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" + "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
) % [ ) % [
npc.npc_name, npc.npc_name,
@@ -135,8 +172,11 @@ func _refresh_npc_inspector() -> void:
npc.hunger, npc.hunger,
npc.energy, npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
housemate_text,
reason_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 visible = is_visible
if is_visible: if is_visible:
_refresh_npc_inspector() _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: 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) var visual = active_npc_visuals.get(npc.id)
if visual == null: if visual == null:
return return
if item_id == SimulationIds.RESOURCE_FOOD:
if visual.has_method("set_carried_food_visible"): if visual.has_method("set_carried_food_visible"):
visual.set_carried_food_visible(amount > 0.0) visual.set_carried_food_visible(amount > 0.0)
else: elif item_id == SimulationIds.RESOURCE_WOOD:
push_error("WorldViewManager: NPCVisual has no set_carried_food_visible method") 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: func _on_npc_task_changed(npc: SimNPC, _old_task: StringName, _new_task: StringName) -> void: