Compare commits

...

26 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
admin adedf28417 feat: assign NPC home positions near village houses
Add home_positions export to SimulationManager. When configured, NPCs get homes assigned from the array wrapping around for more NPCs than homes. main.tscn now sets 6 home positions near the three village houses, so each NPC has a real home to walk to at night. Empty home_positions leaves the spawn-point-as-home backward-compatible behavior.
2026-07-08 18:32:08 +02:00
admin cab732a977 feat: sync visual day-night cycle with simulation clock
DayNightCycle now reads time_of_day from SimulationManager group node, falling back to self-tracked elapsed for lookdev scenes. Simulation starts at 06:00 morning (tick 50) by default. TimeDial reads directly from SimulationManager and displays HH:MM format with a 24-hour dial arc. Removed initial_cycle override from JajceLookdev.
2026-07-08 18:04:04 +02:00
admin 4604dc6d5a feat: add NPC schedules with sleep, meal, and work periods
Add ACTION_SLEEP with home-position targeting and energy restoration. Embed schedule periods (SLEEP/MEAL/WORK/DISCRETIONARY) in ActionSelectionSystem with TimeOfDay from SimulationClock. NPCs sleep at night when energy is low, eat during meal periods when hungry, and work/discretionary the rest of the cycle. Home position stored per NPC and serialized through NPCStateRecord with backward-compatible fallback. Cycle duration persisted through save/restore. Includes headless test for sleep period, work period, meal hunger, energy restoration, serialization round-trip, and home targeting without world adapter.
2026-07-08 17:44:39 +02:00
admin 6fdb9ba50f feat: expand resource discovery to 18 finite ResourceNodes
Add 6 new resources: farm_crop_01 (farming), berry_bush_river_02 (river bank), berry_patch_mill_01 (mill foraging), tree_forest_grove_01/02 (deep forest), wood_pile_mill_01 (mill stockpile). Extend tests to 18 resources, >=9 food, >=9 wood, >=6 berry, farming context assertion.
2026-07-08 17:21:05 +02:00
admin 4054e2c0dd feat: strengthen water and foreground silhouettes
- Upgrade river shader with multi-layer UV flow, fresnel edges, foam highlights, and specular sparkle
- Upgrade waterfall shader with dual-band scrolling, distinct foam contrast, and rim transparency
- Widen river (8m) and waterfall (8m) for greater visual presence
- Increase waterfall mist particles to 72 with wider spread and longer lifetime
- Add fortress crenellations (10), three turrets, and prominent ridge banner for readable skyline silhouette
- Remove duplicate banner nodes from JajceWorld instance (now owned by FortressBlockout scene)
- Update BUILD_IN_PUBLIC_PLAN and LEARNING_ROADMAP to reflect completion
2026-07-08 17:17:34 +02:00
admin c6e2d64162 feat: clarify jajce work-site silhouettes 2026-07-08 16:59:20 +02:00
admin 5251644b78 feat: stage simulation garden first read 2026-07-08 16:47:25 +02:00
admin cd0309e224 feat: add cinematic npc task glyphs 2026-07-08 16:31:34 +02:00
admin e813a8b2d5 docs: capture simulation garden baseline 2026-07-08 16:26:06 +02:00
admin 0fa8d80119 docs: capture jajce lookdev baseline 2026-07-08 16:01:08 +02:00
admin 324084f233 feat: bake jajce terrain navigation 2026-07-08 15:53:39 +02:00
admin cc0d6ef548 test: harden jajce terrain navigation 2026-07-08 15:47:01 +02:00
56 changed files with 2299 additions and 219 deletions
+33 -3
View File
@@ -623,12 +623,42 @@ Completed:
6. `JajceWorld` runtime integration with bindings and regression coverage. 6. `JajceWorld` runtime integration with bindings and regression coverage.
7. Bounded Jajce beauty baseline with six terrain layers, water, atmosphere, 7. Bounded Jajce beauty baseline with six terrain layers, water, atmosphere,
foliage motion, building blockouts, and smoke. foliage motion, building blockouts, and smoke.
8. Base Terrain3D collision/navigation guardrails: runtime tests now keep
Terrain3D collision enabled, keep the old greybox ground out of physics, and
verify required navigation paths track the authored height field.
9. Terrain3D-derived playable-loop navigation: `JajceWorld` now uses an
external baked navigation resource generated from Terrain3D source geometry,
and runtime tests reject partial paths that do not reach their targets.
10. `Jajce Lookdev 01` capture and review: a reproducible capture tool writes
the daytime lookdev baseline to `docs/baselines/jajce_lookdev_01.png`, and
the review notes the current strengths and visual follow-ups.
11. `Simulation Garden 01` capture and review: a reproducible runtime capture
tool writes debug and cinematic `main.tscn` baselines to `docs/baselines/`
and verifies live NPC visuals in the terrain-backed village.
12. Runtime task glyphs: active NPCs now keep compact task-intent markers in
cinematic mode while debug names, labels, and inspector UI remain optional.
13. Runtime first-read staging: the capture tool uses an explicit presentation
camera preset, the gameplay camera supports the same staging API, and the
village has authored dirt path strips for the current task loop.
14. Landmark and work-site silhouettes: the ridge landmark, pantry, guard post,
study desk, and rest bench now have compact presentation props that remain
readable in the runtime cinematic frame.
Completed:
15. Water and foreground silhouette strengthening: river/waterfall shaders
now include multi-layer UV flow, fresnel edges, foam highlights, sparkle
specular, and better color depth; the waterfall has distinct foam bands
and rim transparency; mist particle count and spread increased; the river
and waterfall widened; the fortress ridge landmark now has crenellations,
a dedicated banner pole with larger banner, and three turrets for a more
readable silhouette against the sky.
Next: Next:
1. Capture and visually review “Jajce Lookdev 01.” 1. Continue growing resource discovery with finite ResourceNode instances
2. Add the cinematic/debug toggle and repeatable demo reset. placed in meaningful contexts (from LEARNING_ROADMAP). See the roadmap
3. Capture “Simulation Garden 01.” for the next systems priority.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+49 -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:
@@ -670,6 +670,47 @@ Recently completed:
- Activity-site capacity enforcement for rest, study, and patrol target - Activity-site capacity enforcement for rest, study, and patrol target
resolution, derived from NPC target claims rather than presentation-only resolution, derived from NPC target claims rather than presentation-only
counters. counters.
- Terrain3D collision/navigation hardening began: scaffold and runtime tests
now assert Terrain3D collision remains enabled, the hidden greybox ground no
longer provides physics collision, and tested navigation routes stay close to
the authored Terrain3D height field.
- The temporary greybox navigation source was replaced by a project-owned
Terrain3D-derived navigation mesh resource generated by
`tools/bake_jajce_navigation.gd`; route tests now also reject partial paths
that do not reach their requested targets.
- `Jajce Lookdev 01` is captured and reviewed with a project-owned capture
script and a daytime baseline image under `docs/baselines/`.
- `Simulation Garden 01` is captured and reviewed from `main.tscn` with paired
debug/cinematic baselines that verify live NPC visuals in the Terrain3D-backed
village.
- Runtime task glyphs preserve active NPC task intent in cinematic mode while
leaving the simulation task state authoritative.
- Runtime first-read staging now includes a presentation camera preset and
authored path strips that reveal the active village task loop.
- Landmark and work-site silhouette props make the ridge landmark, pantry,
guard, study, and rest sites easier to identify without debug labels.
- Water and foreground silhouettes strengthened: river/waterfall shaders with
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 This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+34 -9
View File
@@ -193,7 +193,14 @@ study, and patrol target typed activity sites. The migration is documented in
- **Main scene:** `res://main.tscn` - **Main scene:** `res://main.tscn`
- **Terrain:** Terrain3D 1.0.2 is installed and enabled - **Terrain:** Terrain3D 1.0.2 is installed and enabled
- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable - **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable
ResourceNode placement, lookdev camera, and tested runtime navigation ResourceNode placement, lookdev camera, and Terrain3D-derived runtime
navigation with collision guardrails
- **Lookdev baseline:** `Jajce Lookdev 01` is captured at
`docs/baselines/jajce_lookdev_01.png` with
`tools/capture_jajce_lookdev.gd`
- **Runtime baseline:** `Simulation Garden 01` is captured as paired debug and
cinematic `main.tscn` images under `docs/baselines/` with
`tools/capture_simulation_garden.gd`
- **Terrain compatibility floor:** Godot 4.4 according to the bundled extension - **Terrain compatibility floor:** Godot 4.4 according to the bundled extension
- **Version control:** Git - **Version control:** Git
- **Primary branch:** `main` - **Primary branch:** `main`
@@ -211,8 +218,9 @@ plugin content, not game architecture.
- The player is a `CharacterBody3D` represented by placeholder primitive - The player is a `CharacterBody3D` represented by placeholder primitive
geometry. geometry.
- WASD movement is camera-relative. - WASD movement is camera-relative.
- The elevated third-person camera rotates with the mouse and uses smoothed - The elevated third-person camera rotates with the mouse, uses smoothed
follow/focus behavior. follow/focus behavior, and exposes an opt-in presentation preset for
reproducible runtime captures.
- Pressing `E` near a berry bush or tree extracts its configured yield into the - Pressing `E` near a berry bush or tree extracts its configured yield into the
village through the same `ResourceNode` contract used by NPCs. village through the same `ResourceNode` contract used by NPCs.
- Guard, study, rest, and food interactions now use typed world sites. - Guard, study, rest, and food interactions now use typed world sites.
@@ -324,11 +332,20 @@ distance with resource `safety_risk`, `comfort_distance`, and
- a centered 512 m Terrain3D landscape split across four regions; - a centered 512 m Terrain3D landscape split across four regions;
- deterministic shaped terrain data and six game-owned surface layers; - deterministic shaped terrain data and six game-owned surface layers;
- fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke; - fortress, houses, mill, bridge, shader river/waterfall, mist, and smoke;
- authored dirt path strips that make the current village task loop readable;
- compact silhouette props for the ridge landmark, pantry, guard, study, and
rest sites;
- warm sky, fog, shadows, and wind-reactive foliage; - warm sky, fog, shadows, and wind-reactive foliage;
- eight ResourceNodes preserving stable food/wood discovery IDs; - eight ResourceNodes preserving stable food/wood discovery IDs;
- typed village pantry storage and typed guard, study, and rest activity sites; - typed village pantry storage and typed guard, study, and rest activity sites;
- a temporary baked navigation loop with 24 tested routes; - a Terrain3D-derived baked navigation mesh covering the current playable loop;
- a look-development scene and camera. - base collision/navigation guardrails that keep Terrain3D collision enabled,
keep the old greybox ground out of runtime physics, require paths to reach
their targets, and compare required navigation paths against the Terrain3D
height field;
- a look-development scene and camera;
- reproducible lookdev and runtime capture scripts plus review notes under
`docs/baselines/`.
This is a runtime integration proof, not the final terrain composition. This is a runtime integration proof, not the final terrain composition.
Terrain3D startup requires several physics frames before reliable Terrain3D startup requires several physics frames before reliable
@@ -348,8 +365,10 @@ A small village panel displays:
- a Tab-cycled NPC inspector with profession, needs, task state, destination, - a Tab-cycled NPC inspector with profession, needs, task state, destination,
decision reason, and utility scores. decision reason, and utility scores.
NPC name/profession labels, definition-driven colors and props, and the NPC name/profession labels, definition-driven colors and props, carried-food
carried-food visual make active simulation state readable in the world. visuals, and compact task glyphs make active simulation state readable in the
world. The F10 cinematic mode hides debug labels while preserving the task
glyphs.
## Runtime architecture ## Runtime architecture
@@ -534,8 +553,13 @@ These are expected prototype constraints, not necessarily isolated bugs:
mutation APIs, but selection, execution, target resolution, and active-world mutation APIs, but selection, execution, target resolution, and active-world
queries now have focused collaborators. queries now have focused collaborators.
- Path failure and interruption emit a `navigation_failed` signal and send the NPC to wander; this is functional but not yet polished. - Path failure and interruption emit a `navigation_failed` signal and send the NPC to wander; this is functional but not yet polished.
- The old greybox navigation source has been replaced by a project-owned
Terrain3D-derived navigation resource. The current bake is still a first
playable-loop pass, so future terrain sculpting should rerun the bake tool and
recheck reachability rather than hand-editing navigation polygons.
- Terrain3D and the bounded Jajce beauty baseline now run in the main game - Terrain3D and the bounded Jajce beauty baseline now run in the main game
scene; final visual review and character/profession readability remain. scene; `Jajce Lookdev 01` and the runtime `Simulation Garden 01` are captured
as reproducible presentation baselines.
- Combat, companions, factions, politics, trade, rumours, quests, persistence, - Combat, companions, factions, politics, trade, rumours, quests, persistence,
and regional travel do not yet exist. and regional travel do not yet exist.
- Placeholder geometry is sufficient for debugging but not for build-in-public - Placeholder geometry is sufficient for debugging but not for build-in-public
@@ -753,7 +777,8 @@ eating consumes it. Each successful transfer creates a persisted structured
event, and active NPCs visibly carry food through presentation derived from event, and active NPCs visibly carry food through presentation derived from
their inventory. The bounded save-slot slice is also complete, including their inventory. The bounded save-slot slice is also complete, including
validation, atomic replacement recovery, and active-visual rebuilding. validation, atomic replacement recovery, and active-visual rebuilding.
Terrain3D runtime integration and the simulation-garden beauty pass are next. Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
simulation-garden runtime capture are complete.
The coherent visual slice can then aim for: The coherent visual slice can then aim for:
+46
View File
@@ -0,0 +1,46 @@
# Jajce Lookdev 01
- **Captured:** 2026-07-08
- **Scene:** `res://world/jajce/JajceLookdev.tscn`
- **Image:** [`jajce_lookdev_01.png`](jajce_lookdev_01.png)
- **Capture tool:** `res://tools/capture_jajce_lookdev.gd`
## Contract
This checkpoint captures the first readable Jajce visual stage after the
Terrain3D-derived navigation pass. It is a visual composition baseline, not a
gameplay regression test.
The image should show:
- the broad shaped valley terrain;
- the village and fortress blockouts;
- water, waterfall/foam, and bridge silhouettes;
- visible foliage clusters;
- warm daylight/fog treatment;
- stable enough framing to compare future terrain, lighting, and asset passes.
## Review
The scene now reads as a coherent daytime valley rather than the previous dark
startup view. The terrain, homes, bridge, water, waterfall, smoke/mist, and
first foliage clusters are all visible in one frame.
Known follow-up work:
- reduce the heavy haze if it hides terrain color and landmark silhouettes;
- improve fortress/waterfall readability from the beauty-camera angle;
- replace blockout architecture and placeholder resource markers gradually;
- capture a runtime `Simulation Garden 01` view with active NPC behavior.
## Command
Run with a real display driver, not `--headless`; headless Godot uses the dummy
renderer on this machine and cannot read back a viewport image.
```powershell
$env:APPDATA = "$PWD\logs\quality\godot_profile"
$env:LOCALAPPDATA = "$PWD\logs\quality\godot_profile"
& "C:\Users\Rijad\Downloads\tools\Godot_v4.7-stable_win64.exe\Godot_v4.7-stable_win64_console.exe" `
--path "$PWD" --script res://tools/capture_jajce_lookdev.gd
```
+52
View File
@@ -0,0 +1,52 @@
# Simulation Garden 01
Captured: 2026-07-08
## Files
- `docs/baselines/simulation_garden_01_debug.png`
- `docs/baselines/simulation_garden_01_cinematic.png`
- `tools/capture_simulation_garden.gd`
## Capture Command
Run from the project root with the normal renderer:
```powershell
$env:APPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile'
$env:LOCALAPPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile'
& 'C:\Users\Rijad\Downloads\tools\Godot_v4.7-stable_win64.exe\Godot_v4.7-stable_win64_console.exe' --path 'C:\Users\Rijad\Documents\Simulation Game' --script res://tools/capture_simulation_garden.gd
```
The script warms up `main.tscn`, verifies that the simulation and active NPC
visuals exist, captures the F10 debug presentation, then captures the cinematic
presentation with development labels and UI hidden.
## Review
The milestone is valid as a runtime integration checkpoint: the same Jajce
Terrain3D world is running in `main.tscn`, NPCs are alive in the terrain-backed
village, the selected-NPC reason inspector is fed by real task scoring, and the
cinematic/debug toggle produces a shareable view without changing simulation
state. A follow-up pass adds small task glyphs above active NPCs, so the
cinematic view now preserves task intent after the debug labels are hidden. A
camera-staging pass then gives the runtime capture a wider village view and adds
authored dirt path strips that make the task loop readable from the first frame.
The next presentation pass adds compact silhouette props to the ridge landmark,
pantry, guard, study, and rest sites so work locations remain identifiable in
cinematic mode.
The frame also makes the next presentation risks plain:
- the camera still favors verification over composition;
- placeholder characters, houses, and work props remain useful but visibly
prototype-grade;
- terrain texture variation and path strips read better than the old flat map,
but landmarks, water, and foreground silhouettes need stronger first-read
staging;
- task glyphs and work-site props help, but the cinematic view still needs
richer water/foreground shapes and stronger authored silhouettes before it
becomes a strong public-facing shot.
Use this baseline as the first runtime comparison image before adding more
systems or expanding the terrain.
Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

+20 -17
View File
@@ -7,10 +7,10 @@
[ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"] [ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"]
[ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"]
[ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"] [ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"]
[ext_resource type="Script" path="res://simulation/persistence/SaveSlotController.gd" id="10_save"] [ext_resource type="Script" uid="uid://cwvdljsh148wh" path="res://simulation/persistence/SaveSlotController.gd" id="10_save"]
[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="11_jajce"] [ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="11_jajce"]
[ext_resource type="Script" path="res://world/ui/time_dial.gd" id="12_timedial"] [ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
[ext_resource type="Script" path="res://world/demo/DemoController.gd" id="13_demo"] [ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4 radius = 0.4
@@ -22,7 +22,7 @@ height = 1.7
[node name="Main" type="Node3D" unique_id=1850341560] [node name="Main" type="Node3D" unique_id=1850341560]
[node name="JajceWorld" parent="." instance=ExtResource("11_jajce")] [node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "pantry_storage", "guard_site", "study_site")] [node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "pantry_storage", "guard_site", "study_site")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
@@ -53,20 +53,23 @@ target = NodePath("../Player")
follow_speed = 8.0 follow_speed = 8.0
deadzone_right = 2.0 deadzone_right = 2.0
deadzone_forward = 2.0 deadzone_forward = 2.0
max_zoom_offset = Vector3(0, 20, 15)
[node name="Camera3D" type="Camera3D" parent="CameraRig" unique_id=1992528776] [node name="Camera3D" type="Camera3D" parent="CameraRig" unique_id=1992528776]
current = true current = true
fov = 65.0 fov = 65.0
[node name="ActiveWorldAdapter" type="Node" parent="." 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")
active_world_adapter = NodePath("../ActiveWorldAdapter") active_world_adapter = NodePath("../ActiveWorldAdapter")
home_positions = Array[Vector3]([Vector3(-15, 2, 8.5), Vector3(-13.5, 2, 6.5), Vector3(-8.5, 2, 3), Vector3(-6.5, 2, 1.5), Vector3(-15, 2, -2.5), Vector3(-13.5, 2, -5)])
[node name="SaveSlotController" type="Node" parent="." node_paths=PackedStringArray("simulation_manager")] [node name="SaveSlotController" type="Node" parent="." unique_id=1281154019 node_paths=PackedStringArray("simulation_manager")]
script = ExtResource("10_save") script = ExtResource("10_save")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
@@ -84,11 +87,6 @@ active_npcs_parent = NodePath("../ActiveNPCs")
script = ExtResource("6_7mycd") script = ExtResource("6_7mycd")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
[node name="DemoController" type="Node" parent="." node_paths=PackedStringArray("ui", "active_npcs_parent")]
script = ExtResource("13_demo")
ui = NodePath("../UI")
active_npcs_parent = NodePath("../ActiveNPCs")
[node name="VillagePanel" type="PanelContainer" parent="UI" unique_id=482126250] [node name="VillagePanel" type="PanelContainer" parent="UI" unique_id=482126250]
offset_left = 20.0 offset_left = 20.0
offset_top = 20.0 offset_top = 20.0
@@ -106,7 +104,7 @@ theme_override_constants/margin_bottom = 4
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 10 theme_override_font_sizes/font_size = 10
[node name="NpcInspectorPanel" type="PanelContainer" parent="UI"] [node name="NpcInspectorPanel" type="PanelContainer" parent="UI" unique_id=1165556983]
anchors_preset = 1 anchors_preset = 1
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
@@ -116,24 +114,29 @@ offset_right = -20.0
offset_bottom = 330.0 offset_bottom = 330.0
grow_horizontal = 0 grow_horizontal = 0
[node name="MarginContainer" type="MarginContainer" parent="UI/NpcInspectorPanel"] [node name="MarginContainer" type="MarginContainer" parent="UI/NpcInspectorPanel" unique_id=1227722850]
layout_mode = 2 layout_mode = 2
theme_override_constants/margin_left = 16 theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12 theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16 theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12 theme_override_constants/margin_bottom = 12
[node name="NpcInspectorLabel" type="Label" parent="UI/NpcInspectorPanel/MarginContainer"] [node name="NpcInspectorLabel" type="Label" parent="UI/NpcInspectorPanel/MarginContainer" unique_id=1036784952]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.91, 0.78, 1) theme_override_colors/font_color = Color(0.96, 0.91, 0.78, 1)
theme_override_font_sizes/font_size = 15 theme_override_font_sizes/font_size = 15
text = "Villager inspector" text = "Villager inspector"
[node name="TimeDial" type="Control" parent="UI" node_paths=PackedStringArray("day_night_cycle")] [node name="TimeDial" type="Control" parent="UI" unique_id=191345668]
layout_mode = 0 layout_mode = 3
anchors_preset = 0
offset_left = 880.0 offset_left = 880.0
offset_top = 6.0 offset_top = 6.0
offset_right = 940.0 offset_right = 940.0
offset_bottom = 66.0 offset_bottom = 66.0
script = ExtResource("12_timedial") script = ExtResource("12_timedial")
day_night_cycle = NodePath("../JajceWorld/DayNightCycle")
[node name="DemoController" type="Node" parent="." unique_id=644713371 node_paths=PackedStringArray("ui", "active_npcs_parent")]
script = ExtResource("13_demo")
ui = NodePath("../UI")
active_npcs_parent = NodePath("../ActiveNPCs")
+19 -2
View File
@@ -6,6 +6,7 @@ extends Node3D
@export var follow_speed := 12.0 @export var follow_speed := 12.0
@export var rotation_smooth_speed := 18.0 @export var rotation_smooth_speed := 18.0
@export var focus_smooth_speed := 14.0 @export var focus_smooth_speed := 14.0
@export var pitch_degrees := -55.0
@export var mouse_sensitivity := 0.002 @export var mouse_sensitivity := 0.002
@@ -32,7 +33,7 @@ func _ready() -> void:
if target: if target:
focus_position = target.global_position focus_position = target.global_position
desired_focus_position = focus_position desired_focus_position = focus_position
global_position = focus_position + min_zoom_offset.lerp(max_zoom_offset, zoom_smooth) _apply_camera_transform()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
@@ -59,7 +60,7 @@ func _physics_process(delta: float) -> void:
smoothed_yaw = lerp_angle(smoothed_yaw, yaw, rot_t) smoothed_yaw = lerp_angle(smoothed_yaw, yaw, rot_t)
rotation = Vector3(deg_to_rad(-55), smoothed_yaw, 0) rotation = Vector3(deg_to_rad(pitch_degrees), smoothed_yaw, 0)
var target_pos := target.global_position var target_pos := target.global_position
var diff := target_pos - desired_focus_position var diff := target_pos - desired_focus_position
@@ -89,3 +90,19 @@ func _physics_process(delta: float) -> void:
var desired_position := focus_position + rotated_offset var desired_position := focus_position + rotated_offset
global_position = global_position.lerp(desired_position, follow_t) global_position = global_position.lerp(desired_position, follow_t)
func apply_presentation_preset(focus: Vector3, yaw_degrees: float, zoom_value: float) -> void:
yaw = deg_to_rad(yaw_degrees)
smoothed_yaw = yaw
zoom = clampf(zoom_value, 0.0, 1.0)
zoom_smooth = zoom
focus_position = focus
desired_focus_position = focus
_apply_camera_transform()
func _apply_camera_transform() -> void:
rotation = Vector3(deg_to_rad(pitch_degrees), smoothed_yaw, 0)
var active_offset := min_zoom_offset.lerp(max_zoom_offset, zoom_smooth)
global_position = focus_position + active_offset.rotated(Vector3.UP, smoothed_yaw)
+90
View File
@@ -16,6 +16,8 @@ const DEBUG_LOGS := true
@onready var profession_prop: MeshInstance3D = $ProfessionProp @onready var profession_prop: MeshInstance3D = $ProfessionProp
@onready var profession_label: Label3D = $ProfessionLabel @onready var profession_label: Label3D = $ProfessionLabel
@onready var carried_food_visual: MeshInstance3D = $CarriedFood @onready var carried_food_visual: MeshInstance3D = $CarriedFood
@onready var task_glyph_root: Node3D = $TaskGlyphRoot
@onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph
var has_reported_arrival := false var has_reported_arrival := false
var sim_id: int = -1 var sim_id: int = -1
@@ -51,6 +53,7 @@ func setup_from_sim(npc: SimNPC) -> void:
name = "NPC_%s_%s" % [sim_id, npc_name] name = "NPC_%s_%s" % [sim_id, npc_name]
_apply_profession_presentation() _apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0) set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
set_task_presentation(npc.current_task, npc.task_state)
func _apply_profession_presentation() -> void: func _apply_profession_presentation() -> void:
@@ -114,6 +117,18 @@ func set_carried_food_visible(is_visible: bool) -> void:
carried_food_visual.visible = is_visible and not is_dead_visual carried_food_visual.visible = is_visible and not is_dead_visual
func set_task_presentation(action_id: StringName, task_state: StringName) -> void:
if task_glyph_root == null or task_glyph == null:
return
if is_dead_visual or not _should_show_task_glyph(action_id, task_state):
task_glyph_root.visible = false
return
task_glyph.mesh = _create_task_glyph_mesh(action_id)
task_glyph.material_override = _create_task_glyph_material(action_id)
task_glyph_root.visible = task_glyph.mesh != null
func set_debug_overlay_visible(is_visible: bool) -> void: func set_debug_overlay_visible(is_visible: bool) -> void:
debug_overlay_visible = is_visible debug_overlay_visible = is_visible
if profession_label != null: if profession_label != null:
@@ -251,6 +266,7 @@ func apply_dead_visual_state() -> void:
carried_food_visual.visible = false carried_food_visual.visible = false
profession_prop.visible = false profession_prop.visible = false
profession_label.visible = false profession_label.visible = false
task_glyph_root.visible = false
path_request_id += 1 path_request_id += 1
velocity = Vector3.ZERO velocity = Vector3.ZERO
current_path = PackedVector3Array() current_path = PackedVector3Array()
@@ -269,3 +285,77 @@ func apply_dead_visual_state() -> void:
collision_layer = 0 collision_layer = 0
collision_mask = 0 collision_mask = 0
debug_log("Applied dead visual state") debug_log("Applied dead visual state")
func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool:
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
return false
return action_id not in [
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD,
SimulationIds.ACTION_WANDER,
]
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
task_glyph.rotation_degrees = Vector3.ZERO
match action_id:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
var food := SphereMesh.new()
food.radius = 0.18
food.height = 0.26
return food
SimulationIds.ACTION_GATHER_WOOD:
var wood := BoxMesh.new()
wood.size = Vector3(0.18, 0.42, 0.18)
task_glyph.rotation_degrees = Vector3(0, 0, -18)
return wood
SimulationIds.ACTION_DEPOSIT_FOOD:
var crate := BoxMesh.new()
crate.size = Vector3(0.34, 0.22, 0.34)
return crate
SimulationIds.ACTION_PATROL:
var shield := CylinderMesh.new()
shield.top_radius = 0.2
shield.bottom_radius = 0.2
shield.height = 0.1
task_glyph.rotation_degrees = Vector3(90, 0, 0)
return shield
SimulationIds.ACTION_STUDY:
var book := BoxMesh.new()
book.size = Vector3(0.36, 0.08, 0.24)
return book
SimulationIds.ACTION_REST:
var rest := SphereMesh.new()
rest.radius = 0.2
rest.height = 0.12
return rest
return null
func _create_task_glyph_material(action_id: StringName) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.roughness = 0.65
material.emission_enabled = true
material.emission_energy_multiplier = 0.35
var color := _get_task_glyph_color(action_id)
material.albedo_color = color
material.emission = color
return material
func _get_task_glyph_color(action_id: StringName) -> Color:
match action_id:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
return Color(0.95, 0.42, 0.25, 1.0)
SimulationIds.ACTION_GATHER_WOOD:
return Color(0.58, 0.34, 0.16, 1.0)
SimulationIds.ACTION_DEPOSIT_FOOD:
return Color(0.95, 0.72, 0.32, 1.0)
SimulationIds.ACTION_PATROL:
return Color(0.36, 0.58, 0.95, 1.0)
SimulationIds.ACTION_STUDY:
return Color(0.62, 0.46, 0.9, 1.0)
SimulationIds.ACTION_REST:
return Color(0.4, 0.78, 0.72, 1.0)
return Color(0.9, 0.9, 0.85, 1.0)
+6
View File
@@ -55,6 +55,12 @@ visible = false
transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0) transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0)
mesh = SubResource("SphereMesh_food") mesh = SubResource("SphereMesh_food")
[node name="TaskGlyphRoot" type="Node3D" parent="."]
visible = false
position = Vector3(0, 2.25, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."] [node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
path_desired_distance = 0.3 path_desired_distance = 0.3
target_desired_distance = 0.5 target_desired_distance = 0.5
+5 -1
View File
@@ -16,10 +16,11 @@ var intelligence: float
var current_task: StringName = SimulationIds.ACTION_IDLE var current_task: StringName = SimulationIds.ACTION_IDLE
var task_state: StringName = TASK_STATE_IDLE var task_state: StringName = TASK_STATE_IDLE
var position: Vector3 var position: Vector3
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
@@ -31,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
@@ -56,6 +59,7 @@ func _init(
position = Vector3( position = Vector3(
random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0) random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0)
) )
home_position = position
func die_from_starvation() -> void: func die_from_starvation() -> void:
+6
View File
@@ -4,6 +4,7 @@ extends RefCounted
var tick_interval: float var tick_interval: float
var accumulator := 0.0 var accumulator := 0.0
var elapsed_ticks := 0 var elapsed_ticks := 0
var cycle_duration_seconds := 240.0
func _init(interval: float = 1.0) -> void: func _init(interval: float = 1.0) -> void:
@@ -23,3 +24,8 @@ func advance(delta: float) -> int:
func reset() -> void: func reset() -> void:
accumulator = 0.0 accumulator = 0.0
elapsed_ticks = 0 elapsed_ticks = 0
func time_of_day() -> float:
var total_seconds := elapsed_ticks * tick_interval + accumulator
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
+270 -15
View File
@@ -15,8 +15,10 @@ var village := SimVillage.new()
var npcs: Array[SimNPC] = [] var npcs: Array[SimNPC] = []
@export var tick_interval := 1.2 @export var tick_interval := 1.2
@export var simulation_seed: int = 1337 @export var simulation_seed: int = 1337
@export var cycle_duration_seconds := 240.0
@export var debug_logs := true @export var debug_logs := true
@export var active_world_adapter: Node @export var active_world_adapter: Node
@export var home_positions: Array[Vector3] = []
var clock: SimulationClock var clock: SimulationClock
var tick_count := 0 var tick_count := 0
@@ -29,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"]
@@ -42,6 +47,8 @@ func _ready() -> void:
set_process(false) set_process(false)
return return
clock = SimulationClock.new(tick_interval) clock = SimulationClock.new(tick_interval)
clock.cycle_duration_seconds = cycle_duration_seconds
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
village.debug_logs = debug_logs village.debug_logs = debug_logs
_initialize_storage() _initialize_storage()
village.update_modifiers() village.update_modifiers()
@@ -54,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()):
@@ -77,6 +99,17 @@ func generate_npcs() -> void:
npcs.append(npc) npcs.append(npc)
wander_random_sources[i] = _create_random_source(i, 1) wander_random_sources[i] = _create_random_source(i, 1)
var home_count := home_positions.size()
if home_count > 0:
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: func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
var source := RandomNumberGenerator.new() var source := RandomNumberGenerator.new()
@@ -112,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) 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)
@@ -126,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)
@@ -153,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
@@ -163,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] ",
@@ -191,15 +248,34 @@ 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:
consume_npc_food(npc) consume_npc_food(npc)
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 ( 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] ",
@@ -217,6 +293,7 @@ func simulate_tick() -> void:
npc.current_task = SimulationIds.ACTION_IDLE npc.current_task = SimulationIds.ACTION_IDLE
npc.has_travel_target = false npc.has_travel_target = false
npc.target_id = &"" npc.target_id = &""
npc_task_changed.emit(npc, completed_task, npc.current_task)
if debug_logs: if debug_logs:
print( print(
@@ -313,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] ",
@@ -353,14 +447,19 @@ func notify_npc_target_unavailable(npc_id: int) -> void:
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool: func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
for npc in npcs: for npc in npcs:
if npc.id != npc_id: if npc.id != npc_id:
continue continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING: if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false return false
if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.travel_target_position = npc.home_position
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
var result := target_resolver.resolve(npc, origin, self, active_world_adapter) var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
if result.is_empty(): if result.is_empty():
notify_npc_target_unavailable(npc.id) notify_npc_target_unavailable(npc.id)
@@ -411,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,
@@ -429,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() storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
return SimulationIds.STORAGE_VILLAGE_PANTRY,
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create( {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
@@ -470,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)
@@ -488,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)
@@ -600,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(
@@ -609,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 &"village" else (
SimulationIds.STORAGE_VILLAGE_WOODPILE
if resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD
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:
@@ -737,7 +990,8 @@ func create_state_record() -> SimulationStateRecord:
"clock_accumulator": clock.accumulator, "clock_accumulator": clock.accumulator,
"clock_elapsed_ticks": clock.elapsed_ticks, "clock_elapsed_ticks": clock.elapsed_ticks,
"wander_random_streams": wander_streams, "wander_random_streams": wander_streams,
"next_event_id": next_event_id "next_event_id": next_event_id,
"cycle_duration_seconds": clock.cycle_duration_seconds
} }
record.village = VillageStateRecord.capture(village) record.village = VillageStateRecord.capture(village)
for npc in npcs: for npc in npcs:
@@ -781,6 +1035,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
clock = SimulationClock.new(tick_interval) clock = SimulationClock.new(tick_interval)
clock.accumulator = float(record.simulation["clock_accumulator"]) clock.accumulator = float(record.simulation["clock_accumulator"])
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
village = record.village.restore(debug_logs) village = record.village.restore(debug_logs)
storage_states.clear() storage_states.clear()
for storage_record in record.storages: for storage_record in record.storages:
+12 -6
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,17 +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_REST: if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.energy -= 3.0 npc.energy = minf(npc.energy + 1.0, 100.0)
elif npc.current_task == SimulationIds.ACTION_REST:
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
+127 -3
View File
@@ -1,10 +1,92 @@
class_name ActionSelectionSystem class_name ActionSelectionSystem
extends RefCounted extends RefCounted
enum SchedulePeriod {
WORK,
SLEEP,
MEAL,
DISCRETIONARY
}
func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: const SLEEP_BEGIN := 0.88
const SLEEP_END := 0.15
const BREAKFAST_BEGIN := 0.25
const BREAKFAST_END := 0.35
const DINNER_BEGIN := 0.7
const DINNER_END := 0.8
const WORK_BEGIN := 0.28
const WORK_END := 0.85
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
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 npc.energy < 60.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_SLEEP, -1.0, "Night-time sleep; energy is low"
)
if npc.hunger > 75.0 and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
)
if period == SchedulePeriod.MEAL:
if npc.hunger > 50.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Meal-time hunger; eating from inventory"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Meal-time; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
)
if npc.is_starving: if npc.is_starving:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new( return ActionSelectionResult.new(
@@ -33,14 +115,26 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
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"
) )
return _choose_best_work_action(npc, village)
if period == SchedulePeriod.DISCRETIONARY:
var roll := npc.random_source.randf()
if roll < 0.3:
return ActionSelectionResult.new(
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
)
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(
@@ -87,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 [
@@ -129,3 +233,23 @@ func _calculate_score(
if npc.debug_logs: if npc.debug_logs:
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score) print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
return score return score
static func _get_period(time_of_day: float) -> int:
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
return SchedulePeriod.SLEEP
if _in_range(time_of_day, BREAKFAST_BEGIN, BREAKFAST_END):
return SchedulePeriod.MEAL
if _in_range(time_of_day, DINNER_BEGIN, DINNER_END):
return SchedulePeriod.MEAL
if _in_range(time_of_day, WORK_BEGIN, WORK_END):
return SchedulePeriod.WORK
return SchedulePeriod.DISCRETIONARY
static func _in_range(value: float, begin: float, end: float) -> bool:
return value >= begin and value <= end
static func _in_wrap_range(value: float, begin: float, end: float) -> bool:
return value >= begin or value <= end
@@ -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]:
@@ -10,7 +10,9 @@ const ACTION_PATHS := [
"res://simulation/definitions/actions/rest.tres", "res://simulation/definitions/actions/rest.tres",
"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/deposit_wood.tres"
] ]
const PROFESSION_PATHS := [ const PROFESSION_PATHS := [
+7
View File
@@ -3,6 +3,7 @@ extends RefCounted
const ACTION_IDLE := &"idle" const ACTION_IDLE := &"idle"
const ACTION_DEAD := &"dead" const ACTION_DEAD := &"dead"
const ACTION_SLEEP := &"sleep"
const ACTION_GATHER_FOOD := &"gather_food" const ACTION_GATHER_FOOD := &"gather_food"
const ACTION_GATHER_WOOD := &"gather_wood" const ACTION_GATHER_WOOD := &"gather_wood"
const ACTION_PATROL := &"patrol" const ACTION_PATROL := &"patrol"
@@ -12,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"
@@ -27,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"
+10
View File
@@ -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 = &"sleep"
display_name = "Sleep"
default_duration = 8.0
target_type = &"free"
@@ -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]
+28 -1
View File
@@ -26,6 +26,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"current_task": String(npc.current_task), "current_task": String(npc.current_task),
"task_state": String(npc.task_state), "task_state": String(npc.task_state),
"position": [npc.position.x, npc.position.y, npc.position.z], "position": [npc.position.x, npc.position.y, npc.position.z],
"home_position": [npc.home_position.x, npc.home_position.y, npc.home_position.z],
"is_starving": npc.is_starving, "is_starving": npc.is_starving,
"starvation_ticks": npc.starvation_ticks, "starvation_ticks": npc.starvation_ticks,
"starvation_death_threshold": npc.starvation_death_threshold, "starvation_death_threshold": npc.starvation_death_threshold,
@@ -44,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
} }
) )
@@ -137,6 +140,10 @@ func restore(debug_logs: bool) -> SimNPC:
npc.position = Vector3( npc.position = Vector3(
float(saved_position[0]), float(saved_position[1]), float(saved_position[2]) float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
) )
var saved_home: Array = data.get("home_position", saved_position)
npc.home_position = Vector3(
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
)
npc.is_starving = bool(data["is_starving"]) npc.is_starving = bool(data["is_starving"])
npc.starvation_ticks = int(data["starvation_ticks"]) npc.starvation_ticks = int(data["starvation_ticks"])
npc.starvation_death_threshold = int(data["starvation_death_threshold"]) npc.starvation_death_threshold = int(data["starvation_death_threshold"])
@@ -155,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")])
+13 -8
View File
@@ -12,6 +12,7 @@ func _run() -> void:
manager.debug_logs = false manager.debug_logs = false
root.add_child(manager) root.add_child(manager)
manager.set_process(false) manager.set_process(false)
manager.clock.elapsed_ticks = 100
var pantry: StorageStateRecord = manager.get_pantry() var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0) pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
@@ -149,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
), ),
@@ -167,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"
) )
+101 -5
View File
@@ -11,7 +11,8 @@ func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate() var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene) root.add_child(main_scene)
await process_frame await process_frame
await physics_frame for _frame in 10:
await physics_frame
var simulation_manager: Node = main_scene.get_node("SimulationManager") var simulation_manager: Node = main_scene.get_node("SimulationManager")
simulation_manager.set_process(false) simulation_manager.set_process(false)
@@ -26,6 +27,22 @@ func _run() -> void:
terrain.get_camera() == main_scene.get_node("CameraRig/Camera3D"), terrain.get_camera() == main_scene.get_node("CameraRig/Camera3D"),
"Runtime Terrain3D should bind to the gameplay camera" "Runtime Terrain3D should bind to the gameplay camera"
) )
var camera_rig := main_scene.get_node("CameraRig")
_check(
camera_rig.has_method("apply_presentation_preset"),
"Runtime camera should expose a presentation staging preset"
)
var presentation_focus := Vector3(-2.0, 1.2, -0.5)
camera_rig.apply_presentation_preset(presentation_focus, 128.0, 0.95)
_check(
camera_rig.global_position.distance_to(presentation_focus) > 10.0,
"Presentation preset should pull the camera back from the village focus"
)
_check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled")
_check(
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
"Runtime should not keep the temporary greybox navigation ground"
)
_check( _check(
( (
not main_scene.has_node("World") not main_scene.has_node("World")
@@ -36,13 +53,44 @@ func _run() -> void:
"Runtime should not retain duplicate flat-world objects" "Runtime should not retain duplicate flat-world objects"
) )
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes") var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
_check(resource_root.get_child_count() == 12, "Runtime should contain twelve Jajce ResourceNodes") _check(resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes")
_check( _check(
simulation_manager.resource_states.size() == 12, simulation_manager.resource_states.size() == 18,
"Simulation authority should bind all twelve Jajce resources" "Simulation authority should bind all eighteen Jajce resources"
)
var village_root := main_scene.get_node("JajceWorld/VillageRoot")
var path_strips := 0
for child in village_root.get_children():
if child.name.begins_with("Path_"):
path_strips += 1
_check(path_strips >= 4, "Runtime should include authored path strips for first-read composition")
_check(
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
"Runtime should include a readable ridge landmark banner"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillagePantry/PantrySign"),
"Pantry storage should have a readable work-site silhouette"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/GuardPost/GuardPennant"),
"Guard site should have a readable work-site silhouette"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/StudyDesk/OpenBook_A"),
"Study site should have readable study props"
)
_check(
main_scene.has_node("JajceWorld/WorldObjects/ActivitySites/RestBench/RestCanopy"),
"Rest site should have a readable rest silhouette"
) )
var navigation_map: RID = main_scene.get_world_3d().navigation_map var navigation_map: RID = main_scene.get_world_3d().navigation_map
var navigation_region := main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
_check(
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
"Runtime navigation should use the Terrain3D-derived baked mesh resource"
)
await _wait_for_navigation_map(navigation_map) await _wait_for_navigation_map(navigation_map)
NavigationServer3D.map_force_update(navigation_map) NavigationServer3D.map_force_update(navigation_map)
@@ -58,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():
@@ -73,6 +127,12 @@ func _run() -> void:
not path.is_empty(), not path.is_empty(),
"Navigation path should exist from %s to %s" % [origin, destination] "Navigation path should exist from %s to %s" % [origin, destination]
) )
_check_path_tracks_terrain(
terrain, path, "Runtime navigation path should track Terrain3D height"
)
_check_path_reaches_destination(
path, destination, "Runtime navigation path should reach its requested target"
)
var village := SimVillage.new() var village := SimVillage.new()
var worker := SimNPC.new(100, "BaselineWorker", SimulationIds.PROFESSION_SCHOLAR, 5.0, 5.0) var worker := SimNPC.new(100, "BaselineWorker", SimulationIds.PROFESSION_SCHOLAR, 5.0, 5.0)
@@ -95,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",
@@ -102,7 +167,7 @@ func _run() -> void:
) )
if failures.is_empty(): if failures.is_empty():
print("[TEST] Jajce runtime passed: 6 NPCs, 12 resources, typed sites") print("[TEST] Jajce runtime passed: 6 NPCs, 18 resources, typed sites")
quit(0) quit(0)
return return
@@ -126,3 +191,34 @@ func _wait_for_navigation_map(navigation_map: RID) -> void:
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)
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
if path.is_empty():
return
for point in path:
var terrain_height: float = terrain.data.get_height(point)
if is_nan(terrain_height):
failures.append("%s: missing terrain height at %s" % [message, point])
return
if absf(point.y - terrain_height) > 0.85:
failures.append(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
)
return
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void:
if path.is_empty():
return
var final_point := path[path.size() - 1]
var final_xz := Vector2(final_point.x, final_point.z)
var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5:
failures.append(
"%s: final path point %s is too far from %s"
% [message, final_point, destination]
)
+74 -5
View File
@@ -17,7 +17,7 @@ func _run() -> void:
var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate() var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate()
root.add_child(world) root.add_child(world)
await process_frame await process_frame
for frame in 10: for _frame in 10:
await physics_frame await physics_frame
var terrain = world.get_node("TerrainRoot/Terrain3D") var terrain = world.get_node("TerrainRoot/Terrain3D")
@@ -26,6 +26,11 @@ func _run() -> void:
terrain.data_directory == "res://terrain/jajce/data", terrain.data_directory == "res://terrain/jajce/data",
"Terrain3D should use dedicated Jajce data" "Terrain3D should use dedicated Jajce data"
) )
_check(terrain.collision_mode != 0, "Terrain3D collision should be enabled for gameplay")
_check(
not world.has_node("NavigationRegion3D/GreyboxGround"),
"JajceWorld should not keep the temporary greybox navigation ground"
)
_check( _check(
configured_texture_count == 6, configured_texture_count == 6,
( (
@@ -119,13 +124,19 @@ func _run() -> void:
"animal_camp_river_01", "animal_camp_river_01",
"berry_bush_01", "berry_bush_01",
"berry_bush_02", "berry_bush_02",
"berry_bush_river_02",
"berry_patch_mill_01",
"berry_patch_river_01", "berry_patch_river_01",
"berry_patch_south_01", "berry_patch_south_01",
"farm_crop_01",
"tree_01", "tree_01",
"tree_02", "tree_02",
"tree_forest_grove_01",
"tree_forest_grove_02",
"tree_north_outskirts_01", "tree_north_outskirts_01",
"tree_river_resource_01", "tree_river_resource_01",
"tree_south_edge_01", "tree_south_edge_01",
"wood_pile_mill_01",
"wood_pile_village_01" "wood_pile_village_01"
], ],
"Jajce resources should preserve all stable and discoverable IDs" "Jajce resources should preserve all stable and discoverable IDs"
@@ -137,6 +148,7 @@ func _run() -> void:
var berry_context_count := 0 var berry_context_count := 0
var village_context_count := 0 var village_context_count := 0
var outskirt_context_count := 0 var outskirt_context_count := 0
var farming_context_count := 0
for resource in resources: for resource in resources:
var node := resource as ResourceNode var node := resource as ResourceNode
var id := String(node.node_id) var id := String(node.node_id)
@@ -152,6 +164,8 @@ func _run() -> void:
village_context_count += 1 village_context_count += 1
if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"): if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"):
outskirt_context_count += 1 outskirt_context_count += 1
if id.begins_with("farm_"):
farming_context_count += 1
metadata_valid = ( metadata_valid = (
metadata_valid metadata_valid
and node.comfort_distance > 0.0 and node.comfort_distance > 0.0
@@ -159,12 +173,13 @@ func _run() -> void:
and node.safety_risk <= 1.0 and node.safety_risk <= 1.0
) )
_check(metadata_valid, "Jajce resources should define discovery metadata") _check(metadata_valid, "Jajce resources should define discovery metadata")
_check(food_count >= 6, "Jajce should expose at least six finite food resources") _check(food_count >= 9, "Jajce should expose at least nine finite food resources")
_check(wood_count >= 6, "Jajce should expose at least six finite wood resources") _check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts") _check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
_check(berry_context_count >= 4, "Jajce food discovery should include berry contexts") _check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
_check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile") _check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile")
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts") _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
@@ -172,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"
@@ -197,6 +218,10 @@ func _run() -> void:
navigation_region.navigation_mesh.get_polygon_count() > 0, navigation_region.navigation_mesh.get_polygon_count() > 0,
"Jajce navigation mesh should contain baked polygons" "Jajce navigation mesh should contain baked polygons"
) )
_check(
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
"Jajce navigation should use the Terrain3D-derived baked mesh resource"
)
await _wait_for_navigation_map(navigation_map) await _wait_for_navigation_map(navigation_map)
NavigationServer3D.map_force_update(navigation_map) NavigationServer3D.map_force_update(navigation_map)
@@ -205,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())
@@ -215,12 +241,19 @@ func _run() -> void:
not path.is_empty(), not path.is_empty(),
"Jajce navigation path should exist from %s to %s" % [origin, destination] "Jajce navigation path should exist from %s to %s" % [origin, destination]
) )
_check_path_tracks_terrain(
terrain, path, "Jajce navigation path should track Terrain3D height"
)
_check_path_reaches_destination(
path, destination, "Jajce navigation path should reach its requested target"
)
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() >= 6, "Adapter should expose all authored food candidates") _check(food_candidates.size() >= 9, "Adapter should expose all authored food candidates")
var candidate_metadata_valid := true var candidate_metadata_valid := true
for candidate in food_candidates: for candidate in food_candidates:
candidate_metadata_valid = ( candidate_metadata_valid = (
@@ -271,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",
@@ -304,3 +342,34 @@ func _wait_for_navigation_map(navigation_map: RID) -> void:
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)
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
if path.is_empty():
return
for point in path:
var terrain_height: float = terrain.data.get_height(point)
if is_nan(terrain_height):
failures.append("%s: missing terrain height at %s" % [message, point])
return
if absf(point.y - terrain_height) > 0.85:
failures.append(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
)
return
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void:
if path.is_empty():
return
var final_point := path[path.size() - 1]
var final_xz := Vector2(final_point.x, final_point.z)
var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5:
failures.append(
"%s: final path point %s is too far from %s"
% [message, final_point, destination]
)
+246
View File
@@ -0,0 +1,246 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_sleep_period_at_night()
_test_work_period_during_day()
_test_meal_period_hunger()
_test_sleep_restores_energy()
_test_schedule_serialization()
_test_sleep_targets_home()
_test_household_familiarity()
if failures.is_empty():
print("[TEST] NPC schedule passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
root.add_child(manager)
manager.set_process(false)
return manager
func _test_sleep_period_at_night() -> void:
var manager := _create_manager(100)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.hunger = 20.0
npc.energy = 50.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, manager.clock.time_of_day()
)
_check(selection != null, "Night-time should produce an action selection")
_check(
selection.action_id == SimulationIds.ACTION_SLEEP,
"Low-energy NPC should choose SLEEP during night period"
)
_check(
selection.reason.to_lower().contains("night"),
"Sleep selection reason should reference night-time"
)
manager.free()
func _test_work_period_during_day() -> void:
var manager := _create_manager(200)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 100
var time_of_day: float = manager.clock.time_of_day()
_check(
time_of_day > 0.28 and time_of_day < 0.85,
"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
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
_check(selection != null, "Daytime should produce an action selection")
_check(
selection.action_id != SimulationIds.ACTION_SLEEP,
"A well-rested NPC should not choose SLEEP during work period"
)
manager.free()
func _test_meal_period_hunger() -> void:
var manager := _create_manager(300)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 50
var time_of_day: float = manager.clock.time_of_day()
_check(
time_of_day >= 0.25 and time_of_day <= 0.35,
"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
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
_check(selection != null, "Meal period should produce an action selection")
_check(
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
"Hungry NPC during meal period with pantry food should WITHDRAW_FOOD"
)
manager.free()
func _test_sleep_restores_energy() -> void:
var manager := _create_manager(400)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.hunger = 20.0
npc.energy = 30.0
npc.home_position = npc.position
npc.set_task(SimulationIds.ACTION_SLEEP, 4.0)
npc.start_working()
var energy_before := npc.energy
for tick in 4:
manager.simulate_tick()
_check(npc.task_complete, "Sleep should complete after its duration")
_check(
npc.energy > energy_before,
"Completing sleep should restore energy"
)
manager.free()
func _test_schedule_serialization() -> void:
var manager := _create_manager(500)
manager.clock.cycle_duration_seconds = 180.0
manager.clock.elapsed_ticks = 30
var npc: SimNPC = manager.npcs[0]
npc.home_position = Vector3(3.0, 0.0, 4.0)
npc.position = Vector3(1.0, 0.0, 2.0)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(1)
_check(
restored.restore_state_from_json(saved_json),
"Schedule state should survive serialization round-trip"
)
_check(
is_equal_approx(restored.clock.cycle_duration_seconds, 180.0),
"Cycle duration should round-trip through save"
)
var restored_npc: SimNPC = restored.npcs[0]
_check(
restored_npc.home_position.distance_to(Vector3(3.0, 0.0, 4.0)) < 0.01,
"NPC home position should round-trip through save"
)
manager.free()
restored.free()
func _test_sleep_targets_home() -> void:
var manager := _create_manager(600)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.energy = 40.0
npc.home_position = Vector3(5.0, 0.0, 5.0)
npc.position = Vector3(0.0, 0.0, 0.0)
npc.set_task(SimulationIds.ACTION_SLEEP)
_check(npc.task_state == SimNPC.TASK_STATE_TRAVELING, "Sleep should put NPC in traveling state")
var resolved: bool = manager.resolve_npc_target(npc.id, npc.position)
_check(resolved, "Sleep target should resolve without an active world adapter")
_check(
npc.travel_target_position.distance_to(Vector3(5.0, 0.0, 5.0)) < 0.01,
"Sleep should target the NPC home position"
)
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
View File
@@ -0,0 +1 @@
uid://bqv1xjvihivns
+18
View File
@@ -32,6 +32,24 @@ func _run() -> void:
and profession_definition.display_name in visual.get_node("ProfessionLabel").text, and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
"NPC visual should expose a profession prop and readable label" "NPC visual should expose a profession prop and readable label"
) )
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
_check(
visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh,
"NPC visual should expose a compact task glyph for cinematic readability"
)
visual.set_debug_overlay_visible(false)
_check(
not visual.get_node("ProfessionLabel").visible
and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs"
)
visual.set_debug_overlay_visible(true)
visual.set_task_presentation(SimulationIds.ACTION_WANDER, SimNPC.TASK_STATE_TRAVELING)
_check(
not visual.get_node("TaskGlyphRoot").visible,
"Ambient wander should not display a task glyph"
)
var test_decision := ActionSelectionResult.new( var test_decision := ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_FOOD,
-1.0, -1.0,
+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() == 9, "Registry should contain all nine 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")
+74
View File
@@ -0,0 +1,74 @@
extends SceneTree
const OUTPUT_PATH := "res://world/jajce/JajceNavigationMesh.tres"
const WORLD_SCENE_PATH := "res://world/jajce/JajceWorld.tscn"
const TERRAIN_PATH := "TerrainRoot/Terrain3D"
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var world_scene := load(WORLD_SCENE_PATH) as PackedScene
if world_scene == null:
push_error("Could not load %s" % WORLD_SCENE_PATH)
quit(1)
return
var world := world_scene.instantiate() as Node3D
root.add_child(world)
await process_frame
for _frame in 10:
await physics_frame
var terrain := world.get_node(TERRAIN_PATH) as Terrain3D
if terrain == null or terrain.data == null:
push_error("Jajce Terrain3D data is not available")
quit(1)
return
var nav_mesh := _create_navigation_mesh_template()
var bake_aabb: AABB = nav_mesh.filter_baking_aabb
bake_aabb.position += nav_mesh.filter_baking_aabb_offset
var terrain_faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(bake_aabb, false)
if terrain_faces.is_empty():
push_error("Terrain3D produced no navigation source faces")
quit(1)
return
var source_geometry := NavigationMeshSourceGeometryData3D.new()
source_geometry.add_faces(terrain_faces, Transform3D.IDENTITY)
NavigationServer3D.bake_from_source_geometry_data(nav_mesh, source_geometry)
if nav_mesh.get_polygon_count() == 0:
push_error("Baked Jajce navigation mesh contains no polygons")
quit(1)
return
var error := ResourceSaver.save(nav_mesh, OUTPUT_PATH)
if error != OK:
push_error("Could not save %s: %s" % [OUTPUT_PATH, error_string(error)])
quit(1)
return
print(
"[TOOL] Saved %s with %d vertices and %d polygons"
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
)
quit(0)
func _create_navigation_mesh_template() -> NavigationMesh:
var nav_mesh := NavigationMesh.new()
nav_mesh.agent_height = 1.7
nav_mesh.agent_radius = 0.4
nav_mesh.agent_max_climb = 0.55
nav_mesh.agent_max_slope = 35.0
nav_mesh.cell_size = 0.25
nav_mesh.cell_height = 0.1
nav_mesh.filter_baking_aabb = AABB(
Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0)
)
return nav_mesh
+71
View File
@@ -0,0 +1,71 @@
extends SceneTree
const OUTPUT_PATH := "res://docs/baselines/jajce_lookdev_01.png"
const SCENE_PATH := "res://world/jajce/JajceLookdev.tscn"
const CAPTURE_SIZE := Vector2i(1280, 720)
const WARMUP_FRAMES := 90
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = CAPTURE_SIZE
var scene := load(SCENE_PATH) as PackedScene
if scene == null:
push_error("Could not load %s" % SCENE_PATH)
quit(1)
return
var lookdev := scene.instantiate() as Node3D
root.add_child(lookdev)
await process_frame
for _frame in WARMUP_FRAMES:
await process_frame
var image := root.get_texture().get_image()
if image == null or image.is_empty():
push_error("Lookdev capture produced no image")
quit(1)
return
var analysis := _analyze_image(image)
if analysis.get("range", 0.0) < 0.05:
push_error("Lookdev capture is too uniform: %s" % analysis)
quit(1)
return
var error := image.save_png(OUTPUT_PATH)
if error != OK:
push_error("Could not save %s: %s" % [OUTPUT_PATH, error_string(error)])
quit(1)
return
print("[TOOL] Saved %s | %s" % [OUTPUT_PATH, analysis])
quit(0)
func _analyze_image(image: Image) -> Dictionary:
var min_luma := INF
var max_luma := -INF
var total_luma := 0.0
var samples := 0
var step_x = maxi(1, image.get_width() / 64)
var step_y = maxi(1, image.get_height() / 36)
for y in range(0, image.get_height(), step_y):
for x in range(0, image.get_width(), step_x):
var color := image.get_pixel(x, y)
var luma := color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
min_luma = minf(min_luma, luma)
max_luma = maxf(max_luma, luma)
total_luma += luma
samples += 1
return {
"size": "%sx%s" % [image.get_width(), image.get_height()],
"samples": samples,
"average_luma": total_luma / samples,
"range": max_luma - min_luma,
}
+119
View File
@@ -0,0 +1,119 @@
extends SceneTree
const SCENE_PATH := "res://main.tscn"
const DEBUG_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_debug.png"
const CINEMATIC_OUTPUT_PATH := "res://docs/baselines/simulation_garden_01_cinematic.png"
const CAPTURE_SIZE := Vector2i(1280, 720)
const WARMUP_FRAMES := 540
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = CAPTURE_SIZE
var scene := load(SCENE_PATH) as PackedScene
if scene == null:
push_error("Could not load %s" % SCENE_PATH)
quit(1)
return
var main_scene := scene.instantiate()
var simulation_manager := main_scene.get_node("SimulationManager")
simulation_manager.debug_logs = false
root.add_child(main_scene)
await process_frame
_apply_capture_camera(main_scene)
for _frame in WARMUP_FRAMES:
await process_frame
_apply_capture_camera(main_scene)
var active_npcs := main_scene.get_node("ActiveNPCs")
var demo_controller := main_scene.get_node("DemoController")
if simulation_manager.npcs.is_empty() or active_npcs.get_child_count() == 0:
push_error("Simulation Garden capture needs active NPCs")
quit(1)
return
if demo_controller.has_method("toggle_debug_overlay"):
if not demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
if debug_analysis.is_empty():
quit(1)
return
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
await process_frame
for _frame in 30:
await process_frame
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
if cinematic_analysis.is_empty():
quit(1)
return
print(
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
% [debug_analysis, cinematic_analysis]
)
quit(0)
func _capture(output_path: String) -> Dictionary:
var image := root.get_texture().get_image()
if image == null or image.is_empty():
push_error("Simulation Garden capture produced no image")
return {}
var analysis := _analyze_image(image)
if analysis.get("range", 0.0) < 0.05:
push_error("Simulation Garden capture is too uniform: %s" % analysis)
return {}
var error := image.save_png(output_path)
if error != OK:
push_error("Could not save %s: %s" % [output_path, error_string(error)])
return {}
return analysis
func _analyze_image(image: Image) -> Dictionary:
var min_luma := INF
var max_luma := -INF
var total_luma := 0.0
var samples := 0
var step_x = maxi(1, image.get_width() / 64)
var step_y = maxi(1, image.get_height() / 36)
for y in range(0, image.get_height(), step_y):
for x in range(0, image.get_width(), step_x):
var color := image.get_pixel(x, y)
var luma := color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
min_luma = minf(min_luma, luma)
max_luma = maxf(max_luma, luma)
total_luma += luma
samples += 1
return {
"size": "%sx%s" % [image.get_width(), image.get_height()],
"samples": samples,
"average_luma": total_luma / samples,
"range": max_luma - min_luma,
}
func _apply_capture_camera(main_scene: Node) -> void:
var camera_rig := main_scene.get_node("CameraRig")
if camera_rig.has_method("apply_presentation_preset"):
camera_rig.pitch_degrees = -42.0
camera_rig.apply_presentation_preset(Vector3(-2.0, 1.2, -0.5), 128.0, 0.95)
camera_rig.set_physics_process(false)
var camera := main_scene.get_node("CameraRig/Camera3D") as Camera3D
camera.fov = 62.0
+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 {}
+84 -6
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=7 format=3] [gd_scene load_steps=12 format=3]
[sub_resource type="StandardMaterial3D" id="Material_keep"] [sub_resource type="StandardMaterial3D" id="Material_keep"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.45, 0.42, 0.38, 1)
@@ -6,15 +6,15 @@ roughness = 0.85
[sub_resource type="BoxMesh" id="Mesh_keep"] [sub_resource type="BoxMesh" id="Mesh_keep"]
material = SubResource("Material_keep") material = SubResource("Material_keep")
size = Vector3(6, 6, 8) size = Vector3(7, 6, 9)
[sub_resource type="StandardMaterial3D" id="Material_parapet"] [sub_resource type="StandardMaterial3D" id="Material_parapet"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.42, 0.39, 0.35, 1)
roughness = 0.85 roughness = 0.85
[sub_resource type="BoxMesh" id="Mesh_parapet"] [sub_resource type="BoxMesh" id="Mesh_parapet"]
material = SubResource("Material_parapet") material = SubResource("Material_parapet")
size = Vector3(5, 1.5, 7) size = Vector3(6.6, 1.5, 7.8)
[sub_resource type="StandardMaterial3D" id="Material_turret"] [sub_resource type="StandardMaterial3D" id="Material_turret"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.45, 0.42, 0.38, 1)
@@ -26,6 +26,28 @@ top_radius = 0.6
bottom_radius = 0.7 bottom_radius = 0.7
height = 4.0 height = 4.0
[sub_resource type="BoxMesh" id="Mesh_crenellation"]
material = SubResource("Material_parapet")
size = Vector3(0.55, 0.9, 0.7)
[sub_resource type="StandardMaterial3D" id="Material_pole"]
albedo_color = Color(0.24, 0.13, 0.07, 1)
roughness = 0.82
[sub_resource type="CylinderMesh" id="Mesh_pole"]
material = SubResource("Material_pole")
top_radius = 0.08
bottom_radius = 0.1
height = 3.5
[sub_resource type="StandardMaterial3D" id="Material_banner"]
albedo_color = Color(0.68, 0.12, 0.1, 1)
roughness = 0.65
[sub_resource type="BoxMesh" id="Mesh_banner"]
material = SubResource("Material_banner")
size = Vector3(0.14, 2.0, 1.25)
[node name="FortressBlockout" type="Node3D"] [node name="FortressBlockout" type="Node3D"]
[node name="Keep" type="MeshInstance3D" parent="."] [node name="Keep" type="MeshInstance3D" parent="."]
@@ -36,6 +58,62 @@ mesh = SubResource("Mesh_keep")
position = Vector3(0, 6.75, 0) position = Vector3(0, 6.75, 0)
mesh = SubResource("Mesh_parapet") mesh = SubResource("Mesh_parapet")
[node name="Turret" type="MeshInstance3D" parent="."] [node name="Crenellation_01" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 2.0, 3.5) position = Vector3(-2.8, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_02" type="MeshInstance3D" parent="."]
position = Vector3(-1.4, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_03" type="MeshInstance3D" parent="."]
position = Vector3(0, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_04" type="MeshInstance3D" parent="."]
position = Vector3(1.4, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_05" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_06" type="MeshInstance3D" parent="."]
position = Vector3(-2.8, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_07" type="MeshInstance3D" parent="."]
position = Vector3(-1.4, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_08" type="MeshInstance3D" parent="."]
position = Vector3(0, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_09" type="MeshInstance3D" parent="."]
position = Vector3(1.4, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_10" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Turret_A" type="MeshInstance3D" parent="."]
position = Vector3(-3.2, 2.0, -4)
mesh = SubResource("Mesh_turret") mesh = SubResource("Mesh_turret")
[node name="Turret_B" type="MeshInstance3D" parent="."]
position = Vector3(3.2, 2.0, -4)
mesh = SubResource("Mesh_turret")
[node name="Turret_C" type="MeshInstance3D" parent="."]
position = Vector3(3.2, 2.0, 4)
mesh = SubResource("Mesh_turret")
[node name="RidgeBannerPole" type="MeshInstance3D" parent="."]
position = Vector3(0, 8.4, 3.8)
mesh = SubResource("Mesh_pole")
[node name="RidgeBanner" type="MeshInstance3D" parent="."]
position = Vector3(0, 9.2, 4.45)
mesh = SubResource("Mesh_banner")
File diff suppressed because one or more lines are too long
+232 -37
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=26 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"]
@@ -15,17 +15,20 @@
[ext_resource type="Script" path="res://world/jajce/day_night_cycle.gd" id="13_daynight"] [ext_resource type="Script" path="res://world/jajce/day_night_cycle.gd" id="13_daynight"]
[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="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
@@ -44,11 +47,61 @@ size = Vector3(64, 0.2, 64)
[sub_resource type="BoxShape3D" id="Shape_ground"] [sub_resource type="BoxShape3D" id="Shape_ground"]
size = Vector3(64, 0.2, 64) size = Vector3(64, 0.2, 64)
[sub_resource type="StandardMaterial3D" id="Material_path_readability"]
albedo_color = Color(0.52, 0.39, 0.24, 1)
roughness = 0.95
[sub_resource type="BoxMesh" id="Mesh_path_readability"]
material = SubResource("Material_path_readability")
size = Vector3(1, 0.06, 1)
[sub_resource type="StandardMaterial3D" id="Material_landmark_banner"]
albedo_color = Color(0.68, 0.12, 0.1, 1)
roughness = 0.72
[sub_resource type="StandardMaterial3D" id="Material_site_canvas"]
albedo_color = Color(0.82, 0.68, 0.38, 1)
roughness = 0.88
[sub_resource type="StandardMaterial3D" id="Material_site_dark_wood"]
albedo_color = Color(0.24, 0.13, 0.07, 1)
roughness = 0.82
[sub_resource type="StandardMaterial3D" id="Material_site_book"]
albedo_color = Color(0.37, 0.18, 0.62, 1)
roughness = 0.74
[sub_resource type="BoxMesh" id="Mesh_landmark_banner"]
material = SubResource("Material_landmark_banner")
size = Vector3(0.12, 1.7, 1.0)
[sub_resource type="CylinderMesh" id="Mesh_site_pole"]
material = SubResource("Material_site_dark_wood")
top_radius = 0.06
bottom_radius = 0.07
height = 2.2
[sub_resource type="BoxMesh" id="Mesh_site_sign"]
material = SubResource("Material_site_canvas")
size = Vector3(1.0, 0.08, 0.5)
[sub_resource type="BoxMesh" id="Mesh_site_sack"]
material = SubResource("Material_site_canvas")
size = Vector3(0.5, 0.62, 0.42)
[sub_resource type="BoxMesh" id="Mesh_study_book"]
material = SubResource("Material_site_book")
size = Vector3(0.42, 0.08, 0.3)
[sub_resource type="BoxMesh" id="Mesh_rest_canopy"]
material = SubResource("Material_site_canvas")
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"]
@@ -59,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
@@ -78,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)
@@ -118,18 +171,7 @@ size = Vector3(2.8, 0.3, 0.9)
script = ExtResource("3_world") script = ExtResource("3_world")
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="."] [node name="NavigationRegion3D" type="NavigationRegion3D" parent="."]
navigation_mesh = SubResource("NavigationMesh_greybox") navigation_mesh = ExtResource("16_nav")
[node name="GreyboxGround" type="StaticBody3D" parent="NavigationRegion3D"]
collision_layer = 0
collision_mask = 0
[node name="Mesh" type="MeshInstance3D" parent="NavigationRegion3D/GreyboxGround"]
visible = false
mesh = SubResource("Mesh_ground")
[node name="Collision" type="CollisionShape3D" parent="NavigationRegion3D/GreyboxGround"]
shape = SubResource("Shape_ground")
[node name="TerrainRoot" type="Node3D" parent="."] [node name="TerrainRoot" type="Node3D" parent="."]
@@ -180,6 +222,34 @@ position = Vector3(19, 2, 7)
[node name="Bridge" parent="VillageRoot" instance=ExtResource("8_bridge")] [node name="Bridge" parent="VillageRoot" instance=ExtResource("8_bridge")]
position = Vector3(25, 0.5, 0) position = Vector3(25, 0.5, 0)
[node name="Path_Village_Spine" type="MeshInstance3D" parent="VillageRoot"]
position = Vector3(-3, 0.04, -1)
rotation_degrees = Vector3(0, 20, 0)
scale = Vector3(1.25, 1, 13)
cast_shadow = 0
mesh = SubResource("Mesh_path_readability")
[node name="Path_Pantry_Worksites" type="MeshInstance3D" parent="VillageRoot"]
position = Vector3(-2, 0.04, -7.5)
rotation_degrees = Vector3(0, 82, 0)
scale = Vector3(1.15, 1, 8)
cast_shadow = 0
mesh = SubResource("Mesh_path_readability")
[node name="Path_River_Bridge" type="MeshInstance3D" parent="VillageRoot"]
position = Vector3(12, 0.04, 3.5)
rotation_degrees = Vector3(0, 72, 0)
scale = Vector3(1.35, 1, 18)
cast_shadow = 0
mesh = SubResource("Mesh_path_readability")
[node name="Path_Forest_Edge" type="MeshInstance3D" parent="VillageRoot"]
position = Vector3(4, 0.04, -9)
rotation_degrees = Vector3(0, -42, 0)
scale = Vector3(1.1, 1, 9)
cast_shadow = 0
mesh = SubResource("Mesh_path_readability")
[node name="FoliageRoot" type="Node3D" parent="."] [node name="FoliageRoot" type="Node3D" parent="."]
[node name="Tree_West_01" parent="FoliageRoot" instance=ExtResource("4_tree")] [node name="Tree_West_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
@@ -338,11 +408,94 @@ safety_risk = 0.02
comfort_distance = 16.0 comfort_distance = 16.0
discovery_priority = 0.75 discovery_priority = 0.75
[node name="FarmCrop_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-11, 0, 3)
node_id = &"farm_crop_01"
initial_amount = 8.0
yield_per_action = 2.0
safety_risk = 0.02
comfort_distance = 16.0
discovery_priority = 0.85
[node name="BerryBush_River_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(28, 0, -2)
node_id = &"berry_bush_river_02"
initial_amount = 5.0
yield_per_action = 1.5
safety_risk = 0.22
comfort_distance = 28.0
discovery_priority = 0.2
[node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-8, 0, -22)
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="Tree_Forest_Grove_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-3, 0, -24)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="WoodPile_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(21, 0, 1)
node_id = &"wood_pile_mill_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 4.0
yield_per_action = 1.0
safety_risk = 0.03
comfort_distance = 14.0
discovery_priority = 0.8
[node name="BerryPatch_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(14, 0, 3)
node_id = &"berry_patch_mill_01"
initial_amount = 5.0
yield_per_action = 1.5
safety_risk = 0.1
comfort_distance = 20.0
discovery_priority = 0.45
[node name="StorageSites" type="Node3D" parent="WorldObjects"] [node name="StorageSites" type="Node3D" parent="WorldObjects"]
[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"]
position = Vector3(-1.05, 1.1, 1.25)
mesh = SubResource("Mesh_site_pole")
[node name="PantrySign" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-1.05, 1.85, 1.25)
rotation_degrees = Vector3(0, 90, 0)
mesh = SubResource("Mesh_site_sign")
[node name="PantrySack_A" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-0.95, 0.3, -0.65)
scale = Vector3(0.65, 1.0, 0.65)
mesh = SubResource("Mesh_site_sack")
[node name="PantrySack_B" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
position = Vector3(-1.35, 0.22, -0.25)
scale = Vector3(0.5, 0.75, 0.5)
mesh = SubResource("Mesh_site_sack")
[node name="ActivitySites" type="Node3D" parent="WorldObjects"] [node name="ActivitySites" type="Node3D" parent="WorldObjects"]
[node name="GuardPost" type="Node3D" parent="WorldObjects/ActivitySites"] [node name="GuardPost" type="Node3D" parent="WorldObjects/ActivitySites"]
@@ -357,6 +510,15 @@ capacity = 2
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0)
mesh = SubResource("Mesh_guard_post") mesh = SubResource("Mesh_guard_post")
[node name="GuardPennantPole" type="MeshInstance3D" parent="WorldObjects/ActivitySites/GuardPost"]
position = Vector3(0.45, 2.35, 0)
mesh = SubResource("Mesh_site_pole")
[node name="GuardPennant" type="MeshInstance3D" parent="WorldObjects/ActivitySites/GuardPost"]
position = Vector3(0.45, 2.85, 0.48)
scale = Vector3(0.8, 0.55, 0.65)
mesh = SubResource("Mesh_landmark_banner")
[node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/GuardPost"] [node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/GuardPost"]
[node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/GuardPost"] [node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/GuardPost"]
@@ -379,6 +541,16 @@ display_name = "Study Desk"
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.125, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.125, 0)
mesh = SubResource("Mesh_study_desk") mesh = SubResource("Mesh_study_desk")
[node name="OpenBook_A" type="MeshInstance3D" parent="WorldObjects/ActivitySites/StudyDesk"]
position = Vector3(-0.25, 0.32, 0)
rotation_degrees = Vector3(0, 14, 0)
mesh = SubResource("Mesh_study_book")
[node name="OpenBook_B" type="MeshInstance3D" parent="WorldObjects/ActivitySites/StudyDesk"]
position = Vector3(0.22, 0.32, 0.04)
rotation_degrees = Vector3(0, -14, 0)
mesh = SubResource("Mesh_study_book")
[node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/StudyDesk"] [node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/StudyDesk"]
[node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/StudyDesk"] [node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/StudyDesk"]
@@ -402,6 +574,21 @@ capacity = 2
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0)
mesh = SubResource("Mesh_rest_bench") mesh = SubResource("Mesh_rest_bench")
[node name="RestCanopy" type="MeshInstance3D" parent="WorldObjects/ActivitySites/RestBench"]
position = Vector3(0, 1.35, 0)
rotation_degrees = Vector3(0, 0, -6)
mesh = SubResource("Mesh_rest_canopy")
[node name="RestCanopyPost_A" type="MeshInstance3D" parent="WorldObjects/ActivitySites/RestBench"]
position = Vector3(-1.25, 0.72, -0.52)
scale = Vector3(0.8, 0.8, 0.8)
mesh = SubResource("Mesh_site_pole")
[node name="RestCanopyPost_B" type="MeshInstance3D" parent="WorldObjects/ActivitySites/RestBench"]
position = Vector3(1.25, 0.72, 0.52)
scale = Vector3(0.8, 0.8, 0.8)
mesh = SubResource("Mesh_site_pole")
[node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/RestBench"] [node name="InteractionPoint" type="Marker3D" parent="WorldObjects/ActivitySites/RestBench"]
[node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/RestBench"] [node name="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/RestBench"]
@@ -420,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")
+2 -2
View File
@@ -7,10 +7,10 @@ shader = ExtResource("1_water_shader")
[sub_resource type="BoxMesh" id="Mesh_water_body"] [sub_resource type="BoxMesh" id="Mesh_water_body"]
material = SubResource("ShaderMaterial_water") material = SubResource("ShaderMaterial_water")
size = Vector3(5, 0.08, 56) size = Vector3(8, 0.08, 58)
[node name="WaterBody" type="Node3D"] [node name="WaterBody" type="Node3D"]
position = Vector3(25, 0.12, 0) position = Vector3(25, 0.08, 0)
[node name="Mesh" type="MeshInstance3D" parent="."] [node name="Mesh" type="MeshInstance3D" parent="."]
mesh = SubResource("Mesh_water_body") mesh = SubResource("Mesh_water_body")
+2 -2
View File
@@ -7,10 +7,10 @@ shader = ExtResource("1_waterfall_shader")
[sub_resource type="BoxMesh" id="Mesh_waterfall_body"] [sub_resource type="BoxMesh" id="Mesh_waterfall_body"]
material = SubResource("ShaderMaterial_waterfall") material = SubResource("ShaderMaterial_waterfall")
size = Vector3(5, 10, 0.5) size = Vector3(8, 12, 0.5)
[node name="WaterfallBody" type="Node3D"] [node name="WaterfallBody" type="Node3D"]
position = Vector3(25, 5, -24) position = Vector3(25, 6, -24)
[node name="Mesh" type="MeshInstance3D" parent="."] [node name="Mesh" type="MeshInstance3D" parent="."]
mesh = SubResource("Mesh_waterfall_body") mesh = SubResource("Mesh_waterfall_body")
+13 -13
View File
@@ -1,19 +1,19 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=4 format=3]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"] [sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"]
lifetime_randomness = 0.2 lifetime_randomness = 0.35
spread = 180.0 spread = 180.0
gravity = Vector3(0, -0.5, 0) gravity = Vector3(0, -0.35, 0)
initial_velocity_min = 0.3 initial_velocity_min = 0.25
initial_velocity_max = 0.8 initial_velocity_max = 1.2
scale_min = 0.1 scale_min = 0.08
scale_max = 1.5 scale_max = 2.0
color = Color(0.85, 0.95, 0.95, 0.4) color = Color(0.82, 0.94, 0.96, 0.35)
emission_shape = 1 emission_shape = 1
emission_box_extents = Vector3(2.5, 1.0, 0.5) emission_box_extents = Vector3(4.5, 1.5, 1.0)
[sub_resource type="StandardMaterial3D" id="Material_mist"] [sub_resource type="StandardMaterial3D" id="Material_mist"]
albedo_color = Color(0.85, 0.95, 0.95, 0.35) albedo_color = Color(0.82, 0.94, 0.96, 0.28)
transparency = 1 transparency = 1
shading_mode = 0 shading_mode = 0
billboard_mode = 1 billboard_mode = 1
@@ -21,14 +21,14 @@ cull_mode = 0
[sub_resource type="QuadMesh" id="Mesh_mist"] [sub_resource type="QuadMesh" id="Mesh_mist"]
material = SubResource("Material_mist") material = SubResource("Material_mist")
size = Vector2(1.5, 1.5) size = Vector2(1.8, 1.8)
[node name="WaterfallMist" type="GPUParticles3D"] [node name="WaterfallMist" type="GPUParticles3D"]
emitting = true emitting = true
amount = 48 amount = 72
lifetime = 2.5 lifetime = 3.0
one_shot = false one_shot = false
preprocess = 1.0 preprocess = 1.5
speed_scale = 1.0 speed_scale = 1.0
local_coords = true local_coords = true
draw_order = 0 draw_order = 0
+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")
+19 -5
View File
@@ -1,6 +1,7 @@
extends Node extends Node
@export var cycle_duration_seconds := 240.0 @export var cycle_duration_seconds := 240.0
@export_range(0.0, 1.0, 0.01) var initial_cycle := 0.25
@export var directional_light: DirectionalLight3D @export var directional_light: DirectionalLight3D
@export var world_environment: WorldEnvironment @export var world_environment: WorldEnvironment
@@ -35,13 +36,26 @@ const SUNRISE_LIGHT_COLOR := Color(1, 0.6, 0.45, 1)
const SUNRISE_SUNSET_ENERGY := 0.8 const SUNRISE_SUNSET_ENERGY := 0.8
var elapsed := 0.0 var elapsed := 0.0
var _simulation_node: Node
func _process(delta: float) -> void: func _ready() -> void:
elapsed += delta _simulation_node = get_tree().get_first_node_in_group("simulation_manager")
if elapsed >= cycle_duration_seconds: if _simulation_node == null:
elapsed = fmod(elapsed, cycle_duration_seconds) elapsed = cycle_duration_seconds * initial_cycle
var cycle := elapsed / cycle_duration_seconds _apply_light_rotation(initial_cycle)
_apply_environment(initial_cycle)
func _process(_delta: float) -> void:
var cycle: float
if _simulation_node != null and "clock" in _simulation_node:
cycle = _simulation_node.clock.time_of_day()
else:
elapsed += _delta
if elapsed >= cycle_duration_seconds:
elapsed = fmod(elapsed, cycle_duration_seconds)
cycle = elapsed / cycle_duration_seconds
_apply_light_rotation(cycle) _apply_light_rotation(cycle)
_apply_environment(cycle) _apply_environment(cycle)
+43 -9
View File
@@ -1,20 +1,54 @@
shader_type spatial; shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
uniform float wave_strength : hint_range(0.0, 1.0) = 0.15; uniform float wave_strength : hint_range(0.0, 0.5) = 0.08;
uniform float wave_speed : hint_range(0.0, 5.0) = 1.2; uniform float wave_speed : hint_range(0.0, 5.0) = 0.8;
uniform float flow_speed : hint_range(0.0, 3.0) = 0.6;
uniform vec3 deep_color : source_color = vec3(0.06, 0.24, 0.38);
uniform vec3 shallow_color : source_color = vec3(0.18, 0.44, 0.58);
uniform vec3 foam_color : source_color = vec3(0.82, 0.88, 0.86);
uniform float rim_power : hint_range(0.5, 5.0) = 2.5;
uniform float sparkle_strength : hint_range(0.0, 1.0) = 0.12;
global uniform vec3 sun_direction = vec3(0.4, 0.8, 0.4);
void vertex() { void vertex() {
vec3 pos = VERTEX; vec3 pos = VERTEX;
float wave = sin(pos.z * 0.4 + TIME * wave_speed) * wave_strength; float wave_a = sin(pos.x * 0.35 + pos.z * 0.25 + TIME * wave_speed) * wave_strength;
pos.y += wave; float wave_b = cos(pos.x * 0.45 - pos.z * 0.55 + TIME * wave_speed * 0.7) * wave_strength * 0.6;
float wave_c = sin(pos.z * 0.7 + TIME * wave_speed * 1.3) * wave_strength * 0.35;
pos.y += wave_a + wave_b + wave_c;
VERTEX = pos; VERTEX = pos;
} }
void fragment() { void fragment() {
ALBEDO = vec3(0.12, 0.39, 0.55); vec3 view_dir = VIEW;
METALLIC = 0.15; vec3 normal = NORMAL;
ROUGHNESS = 0.1; vec3 view_norm = normalize(view_dir);
EMISSION = vec3(0.05, 0.19, 0.24) * 0.45;
ALPHA = 0.72; float rim = 1.0 - abs(dot(normal, view_norm));
rim = pow(rim, rim_power);
rim = smoothstep(0.15, 0.7, rim);
vec2 uv = UV;
uv.x += TIME * flow_speed * 0.4;
uv.y += TIME * flow_speed * 0.3;
float flow_a = sin(uv.x * 8.0 + uv.y * 5.0) * 0.5 + 0.5;
float flow_b = cos(uv.y * 6.0 - uv.x * 4.0 + TIME * 0.25) * 0.5 + 0.5;
float flow = mix(flow_a, flow_b, 0.5);
vec3 water_color = mix(deep_color, shallow_color, flow * 0.6 + 0.2);
water_color = mix(water_color, foam_color, rim * 0.65);
vec3 half_vec = normalize(view_norm + normalize(sun_direction));
float specular = pow(max(dot(normal, half_vec), 0.0), 120.0);
specular *= smoothstep(0.4, 0.85, rim);
float sparkle = specular * sparkle_strength;
ALBEDO = water_color;
METALLIC = 0.08;
ROUGHNESS = 0.22;
EMISSION = water_color * 0.04 + foam_color * sparkle;
ALPHA = 0.76;
ALPHA_SCISSOR_THRESHOLD = 0.0;
} }
+39 -10
View File
@@ -1,7 +1,12 @@
shader_type spatial; shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
uniform float scroll_speed : hint_range(0.0, 5.0) = 2.0; uniform float scroll_speed : hint_range(0.0, 5.0) = 1.8;
uniform vec3 deep_color : source_color = vec3(0.1, 0.45, 0.55);
uniform vec3 mid_color : source_color = vec3(0.45, 0.78, 0.82);
uniform vec3 foam_color : source_color = vec3(0.9, 0.95, 0.95);
uniform float foam_contrast : hint_range(0.5, 4.0) = 2.2;
uniform float sparkle_strength : hint_range(0.0, 0.8) = 0.25;
void vertex() { void vertex() {
} }
@@ -9,13 +14,37 @@ void vertex() {
void fragment() { void fragment() {
vec2 uv = UV; vec2 uv = UV;
uv.y += TIME * scroll_speed; uv.y += TIME * scroll_speed;
float pattern = fract(uv.y * 4.0);
pattern = smoothstep(0.3, 0.7, pattern); float band_a = fract(uv.y * 5.0 + sin(uv.x * 2.0) * 0.15);
pattern = mix(0.4, 1.0, pattern); band_a = smoothstep(0.2, 0.55, band_a) * smoothstep(0.8, 0.45, band_a);
vec3 base_color = mix(vec3(0.6, 0.85, 0.9), vec3(1.0, 1.0, 1.0), pattern);
ALBEDO = base_color; float band_b = fract(uv.y * 3.5 + cos(uv.x * 2.5 + TIME * 0.3) * 0.12);
ALPHA = mix(0.3, 0.7, pattern); band_b = smoothstep(0.1, 0.5, band_b) * smoothstep(0.85, 0.4, band_b);
EMISSION = vec3(0.36, 0.52, 0.54) * 0.35 * pattern;
METALLIC = 0.0; float foam_a = fract(uv.y * 4.0);
ROUGHNESS = 0.2; foam_a = pow(smoothstep(0.0, 0.12, foam_a) * smoothstep(0.3, 0.15, foam_a), foam_contrast);
float foam_b = fract(uv.y * 6.0 + uv.x * 1.5);
foam_b = pow(smoothstep(0.5, 0.58, foam_b) * smoothstep(0.7, 0.6, foam_b), foam_contrast * 0.8);
float foam = foam_a * 0.7 + foam_b * 0.3;
vec3 base = mix(deep_color, mid_color, band_a * 0.5 + band_b * 0.3);
vec3 color = mix(base, foam_color, foam);
float sparkle = pow(foam_a, 3.0) * sparkle_strength;
float highlight = band_a * band_b * 0.35;
float emission_value = sparkle + highlight * 0.3;
ALBEDO = color;
ALPHA = mix(0.25, 0.78, foam * 0.7 + 0.15);
EMISSION = color * emission_value;
METALLIC = 0.05;
ROUGHNESS = 0.15;
vec3 normal = NORMAL;
vec3 view_dir = VIEW;
vec3 view_norm = normalize(view_dir);
float rim = 1.0 - abs(dot(normal, view_norm));
ALPHA = mix(ALPHA, ALPHA * 0.5, rim * 0.6);
} }
@@ -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
+28 -26
View File
@@ -1,6 +1,6 @@
extends Control extends Control
@export var day_night_cycle: Node @export var simulation_manager: Node
const SIZE_DIAL := 60.0 const SIZE_DIAL := 60.0
const RING_RADIUS := 24.0 const RING_RADIUS := 24.0
@@ -14,7 +14,6 @@ const COLOR_DUSK := Color(0.9, 0.35, 0.2, 1)
const COLOR_BG := Color(0.06, 0.06, 0.08, 0.65) const COLOR_BG := Color(0.06, 0.06, 0.08, 0.65)
var _last_fraction := -1.0 var _last_fraction := -1.0
var _last_color := Color.TRANSPARENT
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
@@ -44,29 +43,37 @@ func _draw() -> void:
var tip_pos := center + tip_dir * RING_RADIUS var tip_pos := center + tip_dir * RING_RADIUS
draw_circle(tip_pos, GLOW_RADIUS, time_color.lightened(0.3)) draw_circle(tip_pos, GLOW_RADIUS, time_color.lightened(0.3))
var label := _phase_label(fraction)
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
if font == null: if font == null:
return return
var time_label := _time_label(fraction)
var font_size := 11 var font_size := 11
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size) var text_size := font.get_string_size(time_label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
var text_x := (SIZE_DIAL - text_size.x) / 2.0 var text_x := (SIZE_DIAL - text_size.x) / 2.0
var text_y := SIZE_DIAL - text_size.y + 4.0 var text_y := SIZE_DIAL - text_size.y + 4.0
draw_string(font, Vector2(text_x, text_y), label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE) draw_string(font, Vector2(text_x, text_y), time_label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
func _cycle_fraction() -> float: func _cycle_fraction() -> float:
if day_night_cycle == null: if simulation_manager != null:
return 0.0 if "clock" in simulation_manager:
var elapsed: float = day_night_cycle.get("elapsed") if "elapsed" in day_night_cycle else 0.0 return simulation_manager.clock.time_of_day()
var duration: float = ( var day_night_cycle := get_node_or_null(day_night_cycle_fallback())
day_night_cycle.get("cycle_duration_seconds") if day_night_cycle != null:
if "cycle_duration_seconds" in day_night_cycle var elapsed: float = day_night_cycle.get("elapsed") if "elapsed" in day_night_cycle else 0.0
else 240.0 var duration: float = (
) day_night_cycle.get("cycle_duration_seconds")
if duration <= 0.0: if "cycle_duration_seconds" in day_night_cycle
return 0.0 else 240.0
return fmod(elapsed / duration, 1.0) )
if duration > 0.0:
return fmod(elapsed / duration, 1.0)
return 0.0
func day_night_cycle_fallback() -> NodePath:
return NodePath("../../JajceWorld/DayNightCycle")
func _time_color(fraction: float) -> Color: func _time_color(fraction: float) -> Color:
@@ -81,13 +88,8 @@ func _time_color(fraction: float) -> Color:
return COLOR_DUSK.lerp(COLOR_NIGHT, smoothstep(0.85, 1.0, fraction)) return COLOR_DUSK.lerp(COLOR_NIGHT, smoothstep(0.85, 1.0, fraction))
func _phase_label(fraction: float) -> String: func _time_label(fraction: float) -> String:
if fraction < 0.1: var total_minutes := int(fraction * 24.0 * 60.0)
return "Night" var hour := total_minutes / 60
if fraction < 0.2: var minute := total_minutes % 60
return "Dawn" return "%02d:%02d" % [hour, minute]
if fraction < 0.65:
return "Day"
if fraction < 0.8:
return "Dusk"
return "Night"
+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]
+19 -5
View File
@@ -36,6 +36,10 @@ func initialize_world_view() -> void:
simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed) simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed)
else: else:
push_error("WorldViewManager: SimulationManager has no npc_inventory_changed signal") push_error("WorldViewManager: SimulationManager has no npc_inventory_changed signal")
if simulation_manager.has_signal("npc_task_changed"):
simulation_manager.npc_task_changed.connect(_on_npc_task_changed)
else:
push_error("WorldViewManager: SimulationManager has no npc_task_changed signal")
if simulation_manager.has_signal("state_restored"): if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_simulation_state_restored) simulation_manager.state_restored.connect(_on_simulation_state_restored)
else: else:
@@ -157,15 +161,25 @@ 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 visual.has_method("set_carried_food_visible"): if item_id == SimulationIds.RESOURCE_FOOD:
visual.set_carried_food_visible(amount > 0.0) if visual.has_method("set_carried_food_visible"):
visual.set_carried_food_visible(amount > 0.0)
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:
var visual = active_npc_visuals.get(npc.id)
if visual == null:
return
if visual.has_method("set_task_presentation"):
visual.set_task_presentation(npc.current_task, npc.task_state)
else: else:
push_error("WorldViewManager: NPCVisual has no set_carried_food_visible method") push_error("WorldViewManager: NPCVisual has no set_task_presentation method")
func _on_simulation_state_restored() -> void: func _on_simulation_state_restored() -> void: