Compare commits
26 Commits
d2eca15ecd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6520c426a | |||
| 6981c7e9bc | |||
| 4b5fc858f8 | |||
| 8a955f3c29 | |||
| 2c3890502a | |||
| 595d67724f | |||
| fce5c533fe | |||
| 8094975094 | |||
| 8a1913c5ae | |||
| c61d286005 | |||
| 5ea83b06ed | |||
| ae0b3dbf89 | |||
| 7aa5c1c979 | |||
| 6008b1023e | |||
| adedf28417 | |||
| cab732a977 | |||
| 4604dc6d5a | |||
| 6fdb9ba50f | |||
| 4054e2c0dd | |||
| c6e2d64162 | |||
| 5251644b78 | |||
| cd0309e224 | |||
| e813a8b2d5 | |||
| 0fa8d80119 | |||
| 324084f233 | |||
| cc0d6ef548 |
@@ -623,12 +623,42 @@ Completed:
|
||||
6. `JajceWorld` runtime integration with bindings and regression coverage.
|
||||
7. Bounded Jajce beauty baseline with six terrain layers, water, atmosphere,
|
||||
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:
|
||||
|
||||
1. Capture and visually review “Jajce Lookdev 01.”
|
||||
2. Add the cinematic/debug toggle and repeatable demo reset.
|
||||
3. Capture “Simulation Garden 01.”
|
||||
1. Continue growing resource discovery with finite ResourceNode instances
|
||||
placed in meaningful contexts (from LEARNING_ROADMAP). See the roadmap
|
||||
for the next systems priority.
|
||||
|
||||
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
|
||||
|
||||
@@ -652,14 +652,14 @@ Completed after the architecture gate:
|
||||
|
||||
The practical next sequence is:
|
||||
|
||||
1. Continue growing resource discovery with finite `ResourceNode` instances
|
||||
placed in meaningful foliage, animal-camp, berry, and village-stockpile
|
||||
contexts. The first distance/safety/comfort scoring pass, twelve authored
|
||||
resources, and placement validation exist; next additions should increase
|
||||
authored variety only when they preserve reachability, stable IDs, scoring
|
||||
metadata, and clear player interaction ranges.
|
||||
2. Expand persistence only when schedules, player state, or a real save menu
|
||||
creates a concrete requirement.
|
||||
1. Expand the structured event feed with actor/target/cause metadata so the
|
||||
inspector can show a readable timeline per NPC. Narrative events for task
|
||||
starts, sleep, and death are recorded; next should add depletion notices,
|
||||
relationship seeds, and village-level event summaries filtered by relevance.
|
||||
2. Seed relationship dimensions (familiarity, trust, obligation) from shared
|
||||
work, meal, and sleep proximity, then expose them through utility
|
||||
considerations so NPCs begin to prefer known colleagues and familiar routes.
|
||||
Start with one lightweight dimension before building the full graph.
|
||||
|
||||
Recently completed:
|
||||
|
||||
@@ -670,6 +670,47 @@ Recently completed:
|
||||
- Activity-site capacity enforcement for rest, study, and patrol target
|
||||
resolution, derived from NPC target claims rather than presentation-only
|
||||
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
|
||||
progress suitable for public development updates.
|
||||
|
||||
+34
-9
@@ -193,7 +193,14 @@ study, and patrol target typed activity sites. The migration is documented in
|
||||
- **Main scene:** `res://main.tscn`
|
||||
- **Terrain:** Terrain3D 1.0.2 is installed and enabled
|
||||
- **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
|
||||
- **Version control:** Git
|
||||
- **Primary branch:** `main`
|
||||
@@ -211,8 +218,9 @@ plugin content, not game architecture.
|
||||
- The player is a `CharacterBody3D` represented by placeholder primitive
|
||||
geometry.
|
||||
- WASD movement is camera-relative.
|
||||
- The elevated third-person camera rotates with the mouse and uses smoothed
|
||||
follow/focus behavior.
|
||||
- The elevated third-person camera rotates with the mouse, uses smoothed
|
||||
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
|
||||
village through the same `ResourceNode` contract used by NPCs.
|
||||
- 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;
|
||||
- deterministic shaped terrain data and six game-owned surface layers;
|
||||
- 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;
|
||||
- eight ResourceNodes preserving stable food/wood discovery IDs;
|
||||
- typed village pantry storage and typed guard, study, and rest activity sites;
|
||||
- a temporary baked navigation loop with 24 tested routes;
|
||||
- a look-development scene and camera.
|
||||
- a Terrain3D-derived baked navigation mesh covering the current playable loop;
|
||||
- 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.
|
||||
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,
|
||||
decision reason, and utility scores.
|
||||
|
||||
NPC name/profession labels, definition-driven colors and props, and the
|
||||
carried-food visual make active simulation state readable in the world.
|
||||
NPC name/profession labels, definition-driven colors and props, carried-food
|
||||
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
|
||||
|
||||
@@ -534,8 +553,13 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
||||
mutation APIs, but selection, execution, target resolution, and active-world
|
||||
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.
|
||||
- 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
|
||||
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,
|
||||
and regional travel do not yet exist.
|
||||
- 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
|
||||
their inventory. The bounded save-slot slice is also complete, including
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 |
@@ -7,10 +7,10 @@
|
||||
[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://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="Script" 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://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
|
||||
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
|
||||
radius = 0.4
|
||||
@@ -22,7 +22,7 @@ height = 1.7
|
||||
|
||||
[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")]
|
||||
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
|
||||
deadzone_right = 2.0
|
||||
deadzone_forward = 2.0
|
||||
max_zoom_offset = Vector3(0, 20, 15)
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="CameraRig" unique_id=1992528776]
|
||||
current = true
|
||||
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")
|
||||
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
|
||||
woodpile_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillageWoodpile")
|
||||
|
||||
[node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")]
|
||||
script = ExtResource("3_lquwl")
|
||||
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")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
|
||||
@@ -84,11 +87,6 @@ active_npcs_parent = NodePath("../ActiveNPCs")
|
||||
script = ExtResource("6_7mycd")
|
||||
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]
|
||||
offset_left = 20.0
|
||||
offset_top = 20.0
|
||||
@@ -106,7 +104,7 @@ theme_override_constants/margin_bottom = 4
|
||||
layout_mode = 2
|
||||
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
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
@@ -116,24 +114,29 @@ offset_right = -20.0
|
||||
offset_bottom = 330.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
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
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
|
||||
theme_override_colors/font_color = Color(0.96, 0.91, 0.78, 1)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Villager inspector"
|
||||
|
||||
[node name="TimeDial" type="Control" parent="UI" node_paths=PackedStringArray("day_night_cycle")]
|
||||
layout_mode = 0
|
||||
[node name="TimeDial" type="Control" parent="UI" unique_id=191345668]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_left = 880.0
|
||||
offset_top = 6.0
|
||||
offset_right = 940.0
|
||||
offset_bottom = 66.0
|
||||
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
@@ -6,6 +6,7 @@ extends Node3D
|
||||
@export var follow_speed := 12.0
|
||||
@export var rotation_smooth_speed := 18.0
|
||||
@export var focus_smooth_speed := 14.0
|
||||
@export var pitch_degrees := -55.0
|
||||
|
||||
@export var mouse_sensitivity := 0.002
|
||||
|
||||
@@ -32,7 +33,7 @@ func _ready() -> void:
|
||||
if target:
|
||||
focus_position = target.global_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:
|
||||
@@ -59,7 +60,7 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
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 diff := target_pos - desired_focus_position
|
||||
@@ -89,3 +90,19 @@ func _physics_process(delta: float) -> void:
|
||||
var desired_position := focus_position + rotated_offset
|
||||
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,8 @@ const DEBUG_LOGS := true
|
||||
@onready var profession_prop: MeshInstance3D = $ProfessionProp
|
||||
@onready var profession_label: Label3D = $ProfessionLabel
|
||||
@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 sim_id: int = -1
|
||||
@@ -51,6 +53,7 @@ func setup_from_sim(npc: SimNPC) -> void:
|
||||
name = "NPC_%s_%s" % [sim_id, npc_name]
|
||||
_apply_profession_presentation()
|
||||
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:
|
||||
@@ -114,6 +117,18 @@ func set_carried_food_visible(is_visible: bool) -> void:
|
||||
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:
|
||||
debug_overlay_visible = is_visible
|
||||
if profession_label != null:
|
||||
@@ -251,6 +266,7 @@ func apply_dead_visual_state() -> void:
|
||||
carried_food_visual.visible = false
|
||||
profession_prop.visible = false
|
||||
profession_label.visible = false
|
||||
task_glyph_root.visible = false
|
||||
path_request_id += 1
|
||||
velocity = Vector3.ZERO
|
||||
current_path = PackedVector3Array()
|
||||
@@ -269,3 +285,77 @@ func apply_dead_visual_state() -> void:
|
||||
collision_layer = 0
|
||||
collision_mask = 0
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
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="."]
|
||||
path_desired_distance = 0.3
|
||||
target_desired_distance = 0.5
|
||||
|
||||
@@ -16,10 +16,11 @@ var intelligence: float
|
||||
var current_task: StringName = SimulationIds.ACTION_IDLE
|
||||
var task_state: StringName = TASK_STATE_IDLE
|
||||
var position: Vector3
|
||||
var home_position: Vector3
|
||||
|
||||
var is_starving := false
|
||||
var starvation_ticks := 0
|
||||
var starvation_death_threshold := 20
|
||||
var starvation_death_threshold := 600
|
||||
var is_dead := false
|
||||
|
||||
var task_duration := 0.0
|
||||
@@ -31,6 +32,8 @@ var has_travel_target := false
|
||||
var inventory: Dictionary = {}
|
||||
var last_task: StringName
|
||||
var random_source: RandomNumberGenerator
|
||||
var familiarity: Dictionary = {}
|
||||
var mourning_ticks := 0
|
||||
var debug_logs := true
|
||||
|
||||
|
||||
@@ -56,6 +59,7 @@ func _init(
|
||||
position = Vector3(
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,7 @@ extends RefCounted
|
||||
var tick_interval: float
|
||||
var accumulator := 0.0
|
||||
var elapsed_ticks := 0
|
||||
var cycle_duration_seconds := 240.0
|
||||
|
||||
|
||||
func _init(interval: float = 1.0) -> void:
|
||||
@@ -23,3 +24,8 @@ func advance(delta: float) -> int:
|
||||
func reset() -> void:
|
||||
accumulator = 0.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)
|
||||
|
||||
+267
-12
@@ -15,8 +15,10 @@ var village := SimVillage.new()
|
||||
var npcs: Array[SimNPC] = []
|
||||
@export var tick_interval := 1.2
|
||||
@export var simulation_seed: int = 1337
|
||||
@export var cycle_duration_seconds := 240.0
|
||||
@export var debug_logs := true
|
||||
@export var active_world_adapter: Node
|
||||
@export var home_positions: Array[Vector3] = []
|
||||
|
||||
var clock: SimulationClock
|
||||
var tick_count := 0
|
||||
@@ -29,6 +31,9 @@ var latest_decisions: Dictionary = {}
|
||||
var action_selector := ActionSelectionSystem.new()
|
||||
var action_executor := ActionExecutionSystem.new()
|
||||
var target_resolver := ActionTargetResolver.new()
|
||||
var speed_index := 2
|
||||
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
||||
signal speed_changed(multiplier: float)
|
||||
|
||||
var names := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
|
||||
|
||||
@@ -42,6 +47,8 @@ func _ready() -> void:
|
||||
set_process(false)
|
||||
return
|
||||
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
|
||||
_initialize_storage()
|
||||
village.update_modifiers()
|
||||
@@ -54,11 +61,26 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var ticks_due := clock.advance(delta)
|
||||
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index])
|
||||
for tick in ticks_due:
|
||||
simulate_tick()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not event is InputEventKey or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_BRACKETLEFT:
|
||||
speed_index = maxi(speed_index - 1, 0)
|
||||
speed_changed.emit(SPEED_LEVELS[speed_index])
|
||||
if debug_logs:
|
||||
print("[SimulationManager] Speed: %.2fx" % SPEED_LEVELS[speed_index])
|
||||
elif event.keycode == KEY_BRACKETRIGHT:
|
||||
speed_index = mini(speed_index + 1, SPEED_LEVELS.size() - 1)
|
||||
speed_changed.emit(SPEED_LEVELS[speed_index])
|
||||
if debug_logs:
|
||||
print("[SimulationManager] Speed: %.2fx" % SPEED_LEVELS[speed_index])
|
||||
|
||||
|
||||
func generate_npcs() -> void:
|
||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||
for i in range(names.size()):
|
||||
@@ -77,6 +99,17 @@ func generate_npcs() -> void:
|
||||
npcs.append(npc)
|
||||
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:
|
||||
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 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:
|
||||
latest_decisions[npc.id] = selection
|
||||
npc_decision_recorded.emit(npc, selection)
|
||||
npc.set_task(selection.action_id, selection.duration_override)
|
||||
var action_def := SimulationDefinitions.get_action(selection.action_id)
|
||||
var action_display := String(selection.action_id)
|
||||
if action_def != null:
|
||||
action_display = action_def.display_name
|
||||
record_narrative_event(
|
||||
SimulationIds.EVENT_TASK_STARTED, npc.id, &"", action_display
|
||||
)
|
||||
|
||||
if old_task != npc.current_task and old_target != &"":
|
||||
release_npc_reservation(npc.id)
|
||||
@@ -126,6 +166,8 @@ func simulate_tick() -> void:
|
||||
release_npc_reservation(npc.id)
|
||||
npc_died.emit(npc)
|
||||
npc_task_changed.emit(npc, old_task, npc.current_task)
|
||||
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
|
||||
_notify_mourning(npc)
|
||||
|
||||
if debug_logs:
|
||||
print("[SimulationManager] NPC died: ", npc.npc_name)
|
||||
@@ -153,6 +195,13 @@ func simulate_tick() -> void:
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
||||
)
|
||||
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
||||
npc.add_inventory(SimulationIds.RESOURCE_WOOD, extracted)
|
||||
npc_inventory_changed.emit(
|
||||
npc,
|
||||
SimulationIds.RESOURCE_WOOD,
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
||||
)
|
||||
else:
|
||||
village.apply_resource_delta(
|
||||
resource_state.get_resource_id(), extracted
|
||||
@@ -163,12 +212,20 @@ func simulate_tick() -> void:
|
||||
resource_state.get_node_id(),
|
||||
(
|
||||
_npc_inventory_id(npc.id)
|
||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
|
||||
if resource_state.get_resource_id() in [
|
||||
SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD
|
||||
]
|
||||
else &"village"
|
||||
),
|
||||
resource_state.get_resource_id(),
|
||||
extracted
|
||||
)
|
||||
if resource_state.get_amount_remaining() <= 0.0:
|
||||
record_narrative_event(
|
||||
SimulationIds.EVENT_RESOURCE_DEPLETED,
|
||||
npc.id,
|
||||
resource_state.get_node_id()
|
||||
)
|
||||
if debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
@@ -191,15 +248,34 @@ func simulate_tick() -> void:
|
||||
release_npc_reservation(npc.id)
|
||||
elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD:
|
||||
deposit_npc_inventory(npc, SimulationIds.RESOURCE_FOOD)
|
||||
elif completed_task == SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||
deposit_npc_wood(npc)
|
||||
elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD:
|
||||
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
||||
elif completed_task == SimulationIds.ACTION_EAT:
|
||||
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 (
|
||||
completed_task
|
||||
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
|
||||
not in [
|
||||
SimulationIds.ACTION_GATHER_FOOD,
|
||||
SimulationIds.ACTION_GATHER_WOOD,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTION_STUDY
|
||||
]
|
||||
):
|
||||
village.apply_npc_task(npc)
|
||||
elif completed_task == SimulationIds.ACTION_PATROL:
|
||||
_consume_wood_for_work(npc, completed_task)
|
||||
village.apply_npc_task(npc)
|
||||
elif completed_task == SimulationIds.ACTION_STUDY:
|
||||
_consume_wood_for_work(npc, completed_task)
|
||||
village.apply_npc_task(npc)
|
||||
elif debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
@@ -217,6 +293,7 @@ func simulate_tick() -> void:
|
||||
npc.current_task = SimulationIds.ACTION_IDLE
|
||||
npc.has_travel_target = false
|
||||
npc.target_id = &""
|
||||
npc_task_changed.emit(npc, completed_task, npc.current_task)
|
||||
|
||||
if debug_logs:
|
||||
print(
|
||||
@@ -313,6 +390,23 @@ func notify_npc_arrived(npc_id: int) -> void:
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
|
||||
if npc.target_id != &"":
|
||||
for other in npcs:
|
||||
if other.id == npc.id or other.is_dead:
|
||||
continue
|
||||
if (
|
||||
other.target_id == npc.target_id
|
||||
and other.task_state in [
|
||||
SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING
|
||||
]
|
||||
):
|
||||
npc.familiarity[other.id] = minf(
|
||||
float(npc.familiarity.get(other.id, 0.0)) + 0.1, 1.0
|
||||
)
|
||||
other.familiarity[npc.id] = minf(
|
||||
float(other.familiarity.get(npc.id, 0.0)) + 0.1, 1.0
|
||||
)
|
||||
|
||||
if debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
@@ -353,14 +447,19 @@ func notify_npc_target_unavailable(npc_id: int) -> void:
|
||||
|
||||
|
||||
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:
|
||||
if npc.id != npc_id:
|
||||
continue
|
||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
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)
|
||||
if result.is_empty():
|
||||
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)
|
||||
|
||||
|
||||
func _notify_mourning(dead_npc: SimNPC) -> void:
|
||||
var best_id := -1
|
||||
var best_score := -1.0
|
||||
for key in dead_npc.familiarity:
|
||||
var other_id: int = int(key)
|
||||
var score: float = float(dead_npc.familiarity[key])
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_id = other_id
|
||||
if best_id < 0:
|
||||
return
|
||||
for npc in npcs:
|
||||
if npc.id == best_id and not npc.is_dead:
|
||||
npc.mourning_ticks = 100
|
||||
if debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
npc.npc_name,
|
||||
" is mourning ",
|
||||
dead_npc.npc_name
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
func _record_economic_event(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
@@ -429,20 +552,89 @@ func _record_economic_event(
|
||||
economic_event_recorded.emit(event)
|
||||
|
||||
|
||||
func record_narrative_event(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
source_id: StringName = &"",
|
||||
action_display: String = ""
|
||||
) -> void:
|
||||
var event := EconomicEventRecord.create_narrative(
|
||||
next_event_id, event_type, tick_count, actor_id, source_id, action_display
|
||||
)
|
||||
next_event_id += 1
|
||||
economic_events.append(event)
|
||||
economic_event_recorded.emit(event)
|
||||
|
||||
|
||||
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
||||
var results: Array[EconomicEventRecord] = []
|
||||
for i in range(economic_events.size() - 1, -1, -1):
|
||||
var event: EconomicEventRecord = economic_events[i]
|
||||
if event.data["actor_id"] == npc_id:
|
||||
results.append(event)
|
||||
if results.size() >= max_count:
|
||||
break
|
||||
results.reverse()
|
||||
return results
|
||||
|
||||
|
||||
func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
|
||||
var results: Array[EconomicEventRecord] = []
|
||||
var start := maxi(economic_events.size() - max_count, 0)
|
||||
for i in range(start, economic_events.size()):
|
||||
results.append(economic_events[i])
|
||||
return results
|
||||
|
||||
|
||||
func get_current_speed() -> String:
|
||||
return "%.2fx" % SPEED_LEVELS[speed_index]
|
||||
|
||||
|
||||
func get_resource_rates() -> Dictionary:
|
||||
var food_consumed := 0.0
|
||||
var wood_consumed := 0.0
|
||||
var recent_ticks := maxi(tick_count - 200, 0)
|
||||
for event in economic_events:
|
||||
if int(event.data["tick"]) < recent_ticks:
|
||||
continue
|
||||
if String(event.data["event_type"]) != "item_consumed":
|
||||
continue
|
||||
if float(event.data["amount"]) <= 0.0:
|
||||
continue
|
||||
if String(event.data["item_id"]) == "food":
|
||||
food_consumed += float(event.data["amount"])
|
||||
elif String(event.data["item_id"]) == "wood":
|
||||
wood_consumed += float(event.data["amount"])
|
||||
var elapsed := maxi(tick_count - recent_ticks, 1)
|
||||
return {
|
||||
"food_per_day": food_consumed / elapsed * 200.0,
|
||||
"wood_per_day": wood_consumed / elapsed * 200.0
|
||||
}
|
||||
|
||||
|
||||
func _initialize_storage() -> void:
|
||||
if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||
_sync_village_food()
|
||||
return
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_FOOD): village.food}
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
{String(SimulationIds.RESOURCE_FOOD): village.food}
|
||||
))
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = (StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
{String(SimulationIds.RESOURCE_WOOD): village.wood}
|
||||
))
|
||||
_sync_village_food()
|
||||
_sync_village_wood()
|
||||
|
||||
|
||||
func get_pantry() -> StorageStateRecord:
|
||||
return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord
|
||||
|
||||
|
||||
func get_woodpile() -> StorageStateRecord:
|
||||
return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord
|
||||
|
||||
|
||||
func register_loaded_storage_nodes() -> void:
|
||||
for candidate in StorageNode.get_all():
|
||||
var node := candidate as StorageNode
|
||||
@@ -470,6 +662,14 @@ func _sync_village_food() -> void:
|
||||
village.update_priorities()
|
||||
|
||||
|
||||
func _sync_village_wood() -> void:
|
||||
var woodpile := get_woodpile()
|
||||
if woodpile != null:
|
||||
village.wood = woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
|
||||
|
||||
func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
|
||||
var available := npc.get_inventory_amount(item_id)
|
||||
var deposited := get_pantry().deposit(item_id, available)
|
||||
@@ -488,6 +688,48 @@ func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
|
||||
return deposited
|
||||
|
||||
|
||||
func deposit_npc_wood(npc: SimNPC) -> float:
|
||||
var available := npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var woodpile := get_woodpile()
|
||||
if woodpile == null:
|
||||
return 0.0
|
||||
var deposited := woodpile.deposit(SimulationIds.RESOURCE_WOOD, available)
|
||||
npc.remove_inventory(SimulationIds.RESOURCE_WOOD, deposited)
|
||||
_sync_village_wood()
|
||||
if deposited > 0.0:
|
||||
npc_inventory_changed.emit(
|
||||
npc, SimulationIds.RESOURCE_WOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
||||
)
|
||||
_record_economic_event(
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
npc.id,
|
||||
_npc_inventory_id(npc.id),
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
SimulationIds.RESOURCE_WOOD,
|
||||
deposited
|
||||
)
|
||||
return deposited
|
||||
|
||||
|
||||
func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> void:
|
||||
var woodpile := get_woodpile()
|
||||
if woodpile == null:
|
||||
return
|
||||
var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
_sync_village_wood()
|
||||
if consumed >= 1.0:
|
||||
_record_economic_event(
|
||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||
npc.id,
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
&"consumed",
|
||||
SimulationIds.RESOURCE_WOOD,
|
||||
consumed
|
||||
)
|
||||
if debug_logs:
|
||||
print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id])
|
||||
|
||||
|
||||
func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float:
|
||||
var withdrawn := get_pantry().withdraw(item_id, amount)
|
||||
npc.add_inventory(item_id, withdrawn)
|
||||
@@ -600,6 +842,9 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
|
||||
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted)
|
||||
_sync_village_food()
|
||||
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
||||
get_woodpile().deposit(SimulationIds.RESOURCE_WOOD, extracted)
|
||||
_sync_village_wood()
|
||||
else:
|
||||
village.apply_resource_delta(resource_state.get_resource_id(), extracted)
|
||||
_record_economic_event(
|
||||
@@ -609,11 +854,19 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
||||
(
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
|
||||
else (
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD
|
||||
else &"village"
|
||||
)
|
||||
),
|
||||
resource_state.get_resource_id(),
|
||||
extracted
|
||||
)
|
||||
if resource_state.get_amount_remaining() <= 0.0:
|
||||
record_narrative_event(
|
||||
SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id()
|
||||
)
|
||||
village_changed.emit(village)
|
||||
|
||||
if debug_logs:
|
||||
@@ -737,7 +990,8 @@ func create_state_record() -> SimulationStateRecord:
|
||||
"clock_accumulator": clock.accumulator,
|
||||
"clock_elapsed_ticks": clock.elapsed_ticks,
|
||||
"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)
|
||||
for npc in npcs:
|
||||
@@ -781,6 +1035,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
clock = SimulationClock.new(tick_interval)
|
||||
clock.accumulator = float(record.simulation["clock_accumulator"])
|
||||
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)
|
||||
storage_states.clear()
|
||||
for storage_record in record.storages:
|
||||
|
||||
@@ -6,6 +6,8 @@ func advance_npc(npc: SimNPC, village: SimVillage) -> void:
|
||||
if npc.is_dead:
|
||||
return
|
||||
_update_needs(npc, village)
|
||||
if npc.mourning_ticks > 0:
|
||||
npc.mourning_ticks -= 1
|
||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING:
|
||||
return
|
||||
npc.task_progress += 1.0
|
||||
@@ -15,17 +17,21 @@ func advance_npc(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:
|
||||
SimNPC.TASK_STATE_IDLE:
|
||||
npc.energy -= 0.5
|
||||
npc.energy -= 0.125
|
||||
SimNPC.TASK_STATE_TRAVELING:
|
||||
npc.energy -= 2.0
|
||||
npc.energy -= 0.5
|
||||
SimNPC.TASK_STATE_WORKING:
|
||||
if npc.current_task != SimulationIds.ACTION_REST:
|
||||
npc.energy -= 3.0
|
||||
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
||||
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:
|
||||
npc.energy -= 0.25
|
||||
npc.energy -= 0.0625
|
||||
npc.hunger = clamp(npc.hunger, 0.0, 100.0)
|
||||
npc.energy = clamp(npc.energy, 0.0, 100.0)
|
||||
npc.is_starving = npc.hunger >= 90.0
|
||||
|
||||
@@ -1,10 +1,92 @@
|
||||
class_name ActionSelectionSystem
|
||||
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:
|
||||
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.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||
return ActionSelectionResult.new(
|
||||
@@ -33,14 +115,26 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
|
||||
)
|
||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0:
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_DEPOSIT_WOOD, -1.0, "Carrying wood for storage"
|
||||
)
|
||||
if npc.energy < 25.0:
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
||||
)
|
||||
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 := {
|
||||
SimulationIds.ACTION_GATHER_FOOD:
|
||||
_calculate_score(
|
||||
@@ -87,6 +181,16 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelecti
|
||||
0.75
|
||||
)
|
||||
}
|
||||
for familiar_id in npc.familiarity:
|
||||
if not familiar_id is int:
|
||||
continue
|
||||
var other_task: StringName
|
||||
for other in all_npcs:
|
||||
if other.id == familiar_id and not other.is_dead:
|
||||
other_task = other.current_task
|
||||
break
|
||||
if other_task in scores:
|
||||
scores[other_task] = float(scores[other_task]) + 0.3
|
||||
var best_action := SimulationIds.ACTION_GATHER_FOOD
|
||||
var best_score: float = scores[best_action]
|
||||
for action_id in [
|
||||
@@ -129,3 +233,23 @@ func _calculate_score(
|
||||
if npc.debug_logs:
|
||||
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", 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 visual_color := Color.WHITE
|
||||
@export var prop_color := Color(0.35, 0.25, 0.15, 1.0)
|
||||
@export_range(-0.12, 0.12, 0.005) var schedule_offset := 0.0
|
||||
|
||||
|
||||
func validate() -> Array[String]:
|
||||
|
||||
@@ -10,7 +10,9 @@ const ACTION_PATHS := [
|
||||
"res://simulation/definitions/actions/rest.tres",
|
||||
"res://simulation/definitions/actions/wander.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 := [
|
||||
|
||||
@@ -3,6 +3,7 @@ extends RefCounted
|
||||
|
||||
const ACTION_IDLE := &"idle"
|
||||
const ACTION_DEAD := &"dead"
|
||||
const ACTION_SLEEP := &"sleep"
|
||||
const ACTION_GATHER_FOOD := &"gather_food"
|
||||
const ACTION_GATHER_WOOD := &"gather_wood"
|
||||
const ACTION_PATROL := &"patrol"
|
||||
@@ -12,6 +13,7 @@ const ACTION_REST := &"rest"
|
||||
const ACTION_WANDER := &"wander"
|
||||
const ACTION_DEPOSIT_FOOD := &"deposit_food"
|
||||
const ACTION_WITHDRAW_FOOD := &"withdraw_food"
|
||||
const ACTION_DEPOSIT_WOOD := &"deposit_wood"
|
||||
|
||||
const PROFESSION_FARMER := &"farmer"
|
||||
const PROFESSION_WOODCUTTER := &"woodcutter"
|
||||
@@ -27,8 +29,13 @@ const RESOURCE_FOOD := &"food"
|
||||
const RESOURCE_WOOD := &"wood"
|
||||
|
||||
const STORAGE_VILLAGE_PANTRY := &"village_pantry"
|
||||
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
|
||||
|
||||
const EVENT_RESOURCE_EXTRACTED := &"resource_extracted"
|
||||
const EVENT_STORAGE_DEPOSITED := &"storage_deposited"
|
||||
const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn"
|
||||
const EVENT_ITEM_CONSUMED := &"item_consumed"
|
||||
const EVENT_NPC_SLEPT := &"npc_slept"
|
||||
const EVENT_NPC_DIED := &"npc_died"
|
||||
const EVENT_TASK_STARTED := &"task_started"
|
||||
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
action_id = &"deposit_wood"
|
||||
display_name = "Deposit Wood"
|
||||
default_duration = 1.0
|
||||
target_type = &"activity"
|
||||
@@ -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"
|
||||
visual_color = Color(0.36, 0.62, 0.28, 1)
|
||||
prop_color = Color(0.65, 0.45, 0.2, 1)
|
||||
schedule_offset = -0.04
|
||||
|
||||
@@ -8,3 +8,4 @@ profession_id = &"guard"
|
||||
display_name = "Guard"
|
||||
visual_color = Color(0.25, 0.42, 0.68, 1)
|
||||
prop_color = Color(0.72, 0.75, 0.78, 1)
|
||||
schedule_offset = 0.02
|
||||
|
||||
@@ -8,3 +8,4 @@ profession_id = &"scholar"
|
||||
display_name = "Scholar"
|
||||
visual_color = Color(0.52, 0.34, 0.68, 1)
|
||||
prop_color = Color(0.78, 0.66, 0.28, 1)
|
||||
schedule_offset = 0.05
|
||||
|
||||
@@ -8,3 +8,4 @@ profession_id = &"woodcutter"
|
||||
display_name = "Woodcutter"
|
||||
visual_color = Color(0.67, 0.34, 0.2, 1)
|
||||
prop_color = Color(0.34, 0.22, 0.14, 1)
|
||||
schedule_offset = -0.02
|
||||
|
||||
@@ -35,6 +35,30 @@ static func create(
|
||||
)
|
||||
|
||||
|
||||
static func create_narrative(
|
||||
event_id: int,
|
||||
event_type: StringName,
|
||||
tick: int,
|
||||
actor_id: int,
|
||||
source_id: StringName,
|
||||
action_display: String = ""
|
||||
) -> EconomicEventRecord:
|
||||
return EconomicEventRecord.new(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"event_id": event_id,
|
||||
"event_type": String(event_type),
|
||||
"tick": tick,
|
||||
"actor_id": actor_id,
|
||||
"source_id": String(source_id),
|
||||
"destination_id": "",
|
||||
"item_id": "",
|
||||
"amount": 0.0,
|
||||
"action_name": action_display
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
@@ -65,10 +89,6 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
||||
normalized["event_id"] < 0
|
||||
or normalized["tick"] < 0
|
||||
or normalized["event_type"].is_empty()
|
||||
or normalized["source_id"].is_empty()
|
||||
or normalized["destination_id"].is_empty()
|
||||
or normalized["item_id"].is_empty()
|
||||
or normalized["amount"] <= 0.0
|
||||
):
|
||||
return null
|
||||
return EconomicEventRecord.new(normalized)
|
||||
@@ -76,3 +96,32 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return data.duplicate(true)
|
||||
|
||||
|
||||
func description(npc_names: Dictionary = {}) -> String:
|
||||
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
|
||||
var item: String = str(data["item_id"])
|
||||
var amount: float = float(data["amount"])
|
||||
var event_type: String = str(data["event_type"])
|
||||
var source: String = str(data["source_id"])
|
||||
var destination: String = str(data["destination_id"])
|
||||
var action_name: String = str(data.get("action_name", ""))
|
||||
match event_type:
|
||||
"resource_extracted":
|
||||
return "%s gathered %.0f %s from %s" % [actor_name, amount, item, source]
|
||||
"storage_deposited":
|
||||
return "%s deposited %.0f %s into %s" % [actor_name, amount, item, destination]
|
||||
"storage_withdrawn":
|
||||
return "%s withdrew %.0f %s from %s" % [actor_name, amount, item, source]
|
||||
"item_consumed":
|
||||
return "%s consumed %.0f %s" % [actor_name, amount, item]
|
||||
"npc_slept":
|
||||
return "%s slept and recovered energy" % actor_name
|
||||
"npc_died":
|
||||
return "%s died from starvation" % actor_name
|
||||
"task_started":
|
||||
return "%s began %s" % [actor_name, action_name if not action_name.is_empty() else item]
|
||||
"resource_depleted":
|
||||
return "%s was depleted" % source
|
||||
_:
|
||||
return "tick %d: %s" % [int(data["tick"]), event_type]
|
||||
|
||||
@@ -26,6 +26,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
|
||||
"current_task": String(npc.current_task),
|
||||
"task_state": String(npc.task_state),
|
||||
"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,
|
||||
"starvation_ticks": npc.starvation_ticks,
|
||||
"starvation_death_threshold": npc.starvation_death_threshold,
|
||||
@@ -44,7 +45,9 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
|
||||
"inventory": npc.inventory.duplicate(true),
|
||||
"last_task": String(npc.last_task),
|
||||
"random_seed": str(npc.random_source.seed),
|
||||
"random_state": str(npc.random_source.state)
|
||||
"random_state": str(npc.random_source.state),
|
||||
"familiarity": _sorted_familiarity(npc.familiarity),
|
||||
"mourning_ticks": npc.mourning_ticks
|
||||
}
|
||||
)
|
||||
|
||||
@@ -137,6 +140,10 @@ func restore(debug_logs: bool) -> SimNPC:
|
||||
npc.position = Vector3(
|
||||
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.starvation_ticks = int(data["starvation_ticks"])
|
||||
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.random_source.state = String(data["random_state"]).to_int()
|
||||
npc.debug_logs = debug_logs
|
||||
var saved_familiarity = data.get("familiarity", {})
|
||||
if saved_familiarity is Array:
|
||||
for pair in saved_familiarity:
|
||||
var pair_dict: Dictionary = pair
|
||||
npc.familiarity[int(pair_dict["id"])] = float(pair_dict["score"])
|
||||
elif saved_familiarity is Dictionary:
|
||||
npc.familiarity = saved_familiarity.duplicate(true)
|
||||
npc.mourning_ticks = int(data.get("mourning_ticks", 0))
|
||||
return npc
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return data.duplicate(true)
|
||||
|
||||
|
||||
static func _sorted_familiarity(familiarity: Dictionary) -> Array:
|
||||
var pairs: Array[Dictionary] = []
|
||||
for key in familiarity:
|
||||
pairs.append({"id": int(key), "score": float(familiarity[key])})
|
||||
pairs.sort_custom(_familiarity_sort)
|
||||
return pairs
|
||||
|
||||
|
||||
static func _familiarity_sort(a: Dictionary, b: Dictionary) -> bool:
|
||||
return int(a["id"]) < int(b["id"])
|
||||
|
||||
@@ -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" 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" 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" 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://cf16txygbbyqb" path="res://terrain/jajce/textures/rock_albedo.png" id="1_rock"]
|
||||
[ext_resource type="Texture2D" uid="uid://d0ihxulegsl2w" path="res://terrain/jajce/textures/grass_albedo.png" id="2_grass"]
|
||||
[ext_resource type="Texture2D" uid="uid://b75t0ego2sdja" path="res://terrain/jajce/textures/soil_albedo.png" id="3_soil"]
|
||||
[ext_resource type="Texture2D" uid="uid://ca7nmo3t6rhtj" path="res://terrain/jajce/textures/dirt_path_albedo.png" id="4_dirt"]
|
||||
[ext_resource type="Texture2D" uid="uid://bsl74sdmhj270" path="res://terrain/jajce/textures/riverbank_stone_albedo.png" id="5_river"]
|
||||
[ext_resource type="Texture2D" uid="uid://e0ahjmsbpagw" path="res://terrain/jajce/textures/compacted_ground_albedo.png" id="6_compact"]
|
||||
[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"]
|
||||
name = "Limestone"
|
||||
id = 0
|
||||
albedo_texture = ExtResource("1_rock")
|
||||
normal_texture = ExtResource("7_normal")
|
||||
normal_depth = 0.0
|
||||
@@ -63,4 +80,5 @@ uv_scale = 0.2
|
||||
detiling_rotation = 0.08
|
||||
|
||||
[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")])
|
||||
|
||||
@@ -12,6 +12,7 @@ func _run() -> void:
|
||||
manager.debug_logs = false
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
manager.clock.elapsed_ticks = 100
|
||||
|
||||
var pantry: StorageStateRecord = manager.get_pantry()
|
||||
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_ITEM_CONSUMED
|
||||
]
|
||||
var transfer_events: Array[EconomicEventRecord] = []
|
||||
for event in manager.economic_events:
|
||||
if float(event.data["amount"]) > 0.0:
|
||||
transfer_events.append(event)
|
||||
_check(
|
||||
manager.economic_events.size() == expected_types.size(),
|
||||
transfer_events.size() == expected_types.size(),
|
||||
"Food transfer loop should emit one structured event per transfer"
|
||||
)
|
||||
if manager.economic_events.size() != expected_types.size():
|
||||
if transfer_events.size() != expected_types.size():
|
||||
return
|
||||
for index in expected_types.size():
|
||||
var event: EconomicEventRecord = manager.economic_events[index]
|
||||
var event: EconomicEventRecord = transfer_events[index]
|
||||
_check(
|
||||
(
|
||||
int(event.data["event_id"]) == index
|
||||
int(event.data["event_id"]) >= 0
|
||||
and StringName(event.data["event_type"]) == expected_types[index]
|
||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
),
|
||||
@@ -167,10 +172,10 @@ func _check_economic_event_chain(manager: Node) -> void:
|
||||
)
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(float(manager.economic_events[0].data["amount"]), 2.0)
|
||||
and is_equal_approx(float(manager.economic_events[1].data["amount"]), 2.0)
|
||||
and is_equal_approx(float(manager.economic_events[2].data["amount"]), 1.0)
|
||||
and is_equal_approx(float(manager.economic_events[3].data["amount"]), 1.0)
|
||||
is_equal_approx(float(transfer_events[0].data["amount"]), 2.0)
|
||||
and is_equal_approx(float(transfer_events[1].data["amount"]), 2.0)
|
||||
and is_equal_approx(float(transfer_events[2].data["amount"]), 1.0)
|
||||
and is_equal_approx(float(transfer_events[3].data["amount"]), 1.0)
|
||||
),
|
||||
"Economic events should record exact transferred quantities"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ func _run() -> void:
|
||||
var main_scene: Node = load("res://main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
await process_frame
|
||||
for _frame in 10:
|
||||
await physics_frame
|
||||
|
||||
var simulation_manager: Node = main_scene.get_node("SimulationManager")
|
||||
@@ -26,6 +27,22 @@ func _run() -> void:
|
||||
terrain.get_camera() == main_scene.get_node("CameraRig/Camera3D"),
|
||||
"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(
|
||||
(
|
||||
not main_scene.has_node("World")
|
||||
@@ -36,13 +53,44 @@ func _run() -> void:
|
||||
"Runtime should not retain duplicate flat-world objects"
|
||||
)
|
||||
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(
|
||||
simulation_manager.resource_states.size() == 12,
|
||||
"Simulation authority should bind all twelve Jajce resources"
|
||||
simulation_manager.resource_states.size() == 18,
|
||||
"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_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)
|
||||
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
|
||||
destinations.append(pantry.get_interaction_position())
|
||||
_check(
|
||||
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
|
||||
"Runtime should expose a typed VillageWoodpile StorageNode"
|
||||
)
|
||||
var woodpile := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
destinations.append(woodpile.get_interaction_position())
|
||||
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
|
||||
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
|
||||
for site in activity_root.get_children():
|
||||
@@ -73,6 +127,12 @@ func _run() -> void:
|
||||
not path.is_empty(),
|
||||
"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 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),
|
||||
"Runtime adapter should route storage actions to village_pantry"
|
||||
)
|
||||
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
|
||||
_check(
|
||||
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
|
||||
"Runtime adapter should route wood deposit to village_woodpile"
|
||||
)
|
||||
var study_target := adapter.get_activity_target(SimulationIds.ACTION_STUDY)
|
||||
_check(
|
||||
study_target.get("target_id", "") == "study_desk",
|
||||
@@ -102,7 +167,7 @@ func _run() -> void:
|
||||
)
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
@@ -126,3 +191,34 @@ func _wait_for_navigation_map(navigation_map: RID) -> void:
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ func _run() -> void:
|
||||
var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||
root.add_child(world)
|
||||
await process_frame
|
||||
for frame in 10:
|
||||
for _frame in 10:
|
||||
await physics_frame
|
||||
|
||||
var terrain = world.get_node("TerrainRoot/Terrain3D")
|
||||
@@ -26,6 +26,11 @@ func _run() -> void:
|
||||
terrain.data_directory == "res://terrain/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(
|
||||
configured_texture_count == 6,
|
||||
(
|
||||
@@ -119,13 +124,19 @@ func _run() -> void:
|
||||
"animal_camp_river_01",
|
||||
"berry_bush_01",
|
||||
"berry_bush_02",
|
||||
"berry_bush_river_02",
|
||||
"berry_patch_mill_01",
|
||||
"berry_patch_river_01",
|
||||
"berry_patch_south_01",
|
||||
"farm_crop_01",
|
||||
"tree_01",
|
||||
"tree_02",
|
||||
"tree_forest_grove_01",
|
||||
"tree_forest_grove_02",
|
||||
"tree_north_outskirts_01",
|
||||
"tree_river_resource_01",
|
||||
"tree_south_edge_01",
|
||||
"wood_pile_mill_01",
|
||||
"wood_pile_village_01"
|
||||
],
|
||||
"Jajce resources should preserve all stable and discoverable IDs"
|
||||
@@ -137,6 +148,7 @@ func _run() -> void:
|
||||
var berry_context_count := 0
|
||||
var village_context_count := 0
|
||||
var outskirt_context_count := 0
|
||||
var farming_context_count := 0
|
||||
for resource in resources:
|
||||
var node := resource as ResourceNode
|
||||
var id := String(node.node_id)
|
||||
@@ -152,6 +164,8 @@ func _run() -> void:
|
||||
village_context_count += 1
|
||||
if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"):
|
||||
outskirt_context_count += 1
|
||||
if id.begins_with("farm_"):
|
||||
farming_context_count += 1
|
||||
metadata_valid = (
|
||||
metadata_valid
|
||||
and node.comfort_distance > 0.0
|
||||
@@ -159,12 +173,13 @@ func _run() -> void:
|
||||
and node.safety_risk <= 1.0
|
||||
)
|
||||
_check(metadata_valid, "Jajce resources should define discovery metadata")
|
||||
_check(food_count >= 6, "Jajce should expose at least six finite food resources")
|
||||
_check(wood_count >= 6, "Jajce should expose at least six finite wood resources")
|
||||
_check(food_count >= 9, "Jajce should expose at least nine finite food 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(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(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")
|
||||
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as 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,
|
||||
"VillagePantry should preserve the stable storage ID"
|
||||
)
|
||||
var woodpile := world.get_node("WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
_check(woodpile != null, "JajceWorld should include a typed VillageWoodpile StorageNode")
|
||||
_check(
|
||||
woodpile.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
"VillageWoodpile should preserve the stable storage ID"
|
||||
)
|
||||
_check(
|
||||
not world.has_node("LegacyActivityMarkers/PantryMarker"),
|
||||
"Pantry should no longer be a legacy activity marker"
|
||||
@@ -197,6 +218,10 @@ func _run() -> void:
|
||||
navigation_region.navigation_mesh.get_polygon_count() > 0,
|
||||
"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)
|
||||
NavigationServer3D.map_force_update(navigation_map)
|
||||
|
||||
@@ -205,6 +230,7 @@ func _run() -> void:
|
||||
for resource in resources:
|
||||
destinations.append((resource as ResourceNode).interaction_point.global_position)
|
||||
destinations.append(pantry.get_interaction_position())
|
||||
destinations.append(woodpile.get_interaction_position())
|
||||
for site in activity_root.get_children():
|
||||
destinations.append((site as ActivitySite).get_interaction_position())
|
||||
|
||||
@@ -215,12 +241,19 @@ func _run() -> void:
|
||||
not path.is_empty(),
|
||||
"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()
|
||||
adapter.pantry_storage = pantry
|
||||
adapter.woodpile_storage = woodpile
|
||||
root.add_child(adapter)
|
||||
var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD)
|
||||
_check(food_candidates.size() >= 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
|
||||
for candidate in food_candidates:
|
||||
candidate_metadata_valid = (
|
||||
@@ -271,6 +304,11 @@ func _run() -> void:
|
||||
storage_target_position.distance_to(pantry.get_interaction_position()) < 0.01,
|
||||
"Storage actions should use the pantry StorageNode interaction point"
|
||||
)
|
||||
var wood_target := adapter.get_activity_target(SimulationIds.ACTION_DEPOSIT_WOOD)
|
||||
_check(
|
||||
wood_target.get("target_id", "") == String(SimulationIds.STORAGE_VILLAGE_WOODPILE),
|
||||
"Wood deposit should target the typed woodpile's stable ID"
|
||||
)
|
||||
var rest_target := adapter.get_activity_target(SimulationIds.ACTION_REST)
|
||||
_check(
|
||||
rest_target.get("target_id", "") == "rest_bench",
|
||||
@@ -304,3 +342,34 @@ func _wait_for_navigation_map(navigation_map: RID) -> void:
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqv1xjvihivns
|
||||
@@ -32,6 +32,24 @@ func _run() -> void:
|
||||
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
|
||||
"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(
|
||||
SimulationIds.ACTION_GATHER_FOOD,
|
||||
-1.0,
|
||||
|
||||
@@ -34,7 +34,7 @@ func _test_registry_integrity() -> void:
|
||||
SimulationDefinitions.get_action(definition.action_id) == definition,
|
||||
"Action lookup should return '%s'" % definition.action_id
|
||||
)
|
||||
_check(action_ids.size() == 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()
|
||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@ class_name ActiveWorldAdapter
|
||||
extends Node
|
||||
|
||||
@export var pantry_storage: StorageNode
|
||||
@export var woodpile_storage: StorageNode
|
||||
|
||||
|
||||
func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
|
||||
@@ -38,6 +39,8 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO)
|
||||
return _get_storage_target(pantry_storage)
|
||||
SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD:
|
||||
return _get_storage_target(pantry_storage)
|
||||
SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||
return _get_storage_target(woodpile_storage)
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
albedo_color = Color(0.45, 0.42, 0.38, 1)
|
||||
@@ -6,15 +6,15 @@ roughness = 0.85
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_keep"]
|
||||
material = SubResource("Material_keep")
|
||||
size = Vector3(6, 6, 8)
|
||||
size = Vector3(7, 6, 9)
|
||||
|
||||
[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
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_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"]
|
||||
albedo_color = Color(0.45, 0.42, 0.38, 1)
|
||||
@@ -26,6 +26,28 @@ top_radius = 0.6
|
||||
bottom_radius = 0.7
|
||||
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="Keep" type="MeshInstance3D" parent="."]
|
||||
@@ -36,6 +58,62 @@ mesh = SubResource("Mesh_keep")
|
||||
position = Vector3(0, 6.75, 0)
|
||||
mesh = SubResource("Mesh_parapet")
|
||||
|
||||
[node name="Turret" type="MeshInstance3D" parent="."]
|
||||
position = Vector3(2.8, 2.0, 3.5)
|
||||
[node name="Crenellation_01" type="MeshInstance3D" parent="."]
|
||||
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")
|
||||
|
||||
[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
@@ -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="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="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="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/WindStrokes.tscn" id="19_windstrokes"]
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/WindSpecks.tscn" id="20_windspecks"]
|
||||
|
||||
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
|
||||
_shader_parameters = {
|
||||
"auto_base_texture": 1,
|
||||
"auto_overlay_texture": 0,
|
||||
"auto_slope": 0.72,
|
||||
"blend_sharpness": 0.82,
|
||||
"auto_base_texture": 0,
|
||||
"auto_overlay_texture": 1,
|
||||
"auto_slope": 0.62,
|
||||
"blend_sharpness": 0.88,
|
||||
"enable_macro_variation": true,
|
||||
"macro_variation1": Color(0.9, 0.94, 0.82, 1),
|
||||
"macro_variation2": Color(0.76, 0.81, 0.7, 1),
|
||||
"macro_variation_slope": 0.38
|
||||
"macro_variation1": Color(0.78, 0.9, 0.42, 1),
|
||||
"macro_variation2": Color(0.55, 0.75, 0.38, 1),
|
||||
"macro_variation_slope": 0.48
|
||||
}
|
||||
world_background = 0
|
||||
auto_shader = true
|
||||
@@ -44,11 +47,61 @@ size = Vector3(64, 0.2, 64)
|
||||
[sub_resource type="BoxShape3D" id="Shape_ground"]
|
||||
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"]
|
||||
sky_top_color = Color(0.23, 0.43, 0.62, 1)
|
||||
sky_horizon_color = Color(0.82, 0.68, 0.5, 1)
|
||||
ground_bottom_color = Color(0.12, 0.17, 0.12, 1)
|
||||
ground_horizon_color = Color(0.62, 0.55, 0.42, 1)
|
||||
sky_top_color = Color(0.28, 0.52, 0.72, 1)
|
||||
sky_horizon_color = Color(0.88, 0.74, 0.52, 1)
|
||||
ground_bottom_color = Color(0.18, 0.24, 0.14, 1)
|
||||
ground_horizon_color = Color(0.68, 0.6, 0.44, 1)
|
||||
sun_angle_max = 18.0
|
||||
|
||||
[sub_resource type="Sky" id="Sky_jajce"]
|
||||
@@ -59,18 +112,18 @@ background_mode = 2
|
||||
sky = SubResource("Sky_jajce")
|
||||
background_energy_multiplier = 0.9
|
||||
ambient_light_source = 3
|
||||
ambient_light_color = Color(0.91, 0.85, 0.72, 1)
|
||||
ambient_light_energy = 0.7
|
||||
ambient_light_color = Color(0.95, 0.9, 0.76, 1)
|
||||
ambient_light_energy = 0.75
|
||||
reflected_light_source = 2
|
||||
tonemap_mode = 2
|
||||
glow_enabled = true
|
||||
glow_normalized = true
|
||||
glow_intensity = 0.34
|
||||
glow_strength = 0.75
|
||||
glow_bloom = 0.08
|
||||
glow_intensity = 0.5
|
||||
glow_strength = 0.9
|
||||
glow_bloom = 0.12
|
||||
fog_enabled = true
|
||||
fog_light_color = Color(0.82, 0.78, 0.66, 1)
|
||||
fog_light_energy = 0.72
|
||||
fog_light_color = Color(0.88, 0.84, 0.7, 1)
|
||||
fog_light_energy = 0.78
|
||||
fog_density = 0.002
|
||||
fog_height = -2.0
|
||||
fog_height_density = 0.08
|
||||
@@ -78,15 +131,15 @@ fog_aerial_perspective = 0.35
|
||||
fog_sky_affect = 0.28
|
||||
volumetric_fog_enabled = true
|
||||
volumetric_fog_density = 0.006
|
||||
volumetric_fog_albedo = Color(0.9, 0.84, 0.72, 1)
|
||||
volumetric_fog_emission = Color(0.08, 0.065, 0.04, 1)
|
||||
volumetric_fog_emission_energy = 0.18
|
||||
volumetric_fog_albedo = Color(0.94, 0.88, 0.75, 1)
|
||||
volumetric_fog_emission = Color(0.1, 0.07, 0.04, 1)
|
||||
volumetric_fog_emission_energy = 0.22
|
||||
volumetric_fog_length = 96.0
|
||||
volumetric_fog_detail_spread = 1.6
|
||||
adjustment_enabled = true
|
||||
adjustment_brightness = 1.04
|
||||
adjustment_contrast = 1.03
|
||||
adjustment_saturation = 1.1
|
||||
adjustment_brightness = 1.08
|
||||
adjustment_contrast = 1.04
|
||||
adjustment_saturation = 1.25
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_guard"]
|
||||
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")
|
||||
|
||||
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="."]
|
||||
navigation_mesh = SubResource("NavigationMesh_greybox")
|
||||
|
||||
[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")
|
||||
navigation_mesh = ExtResource("16_nav")
|
||||
|
||||
[node name="TerrainRoot" type="Node3D" parent="."]
|
||||
|
||||
@@ -180,6 +222,34 @@ position = Vector3(19, 2, 7)
|
||||
[node name="Bridge" parent="VillageRoot" instance=ExtResource("8_bridge")]
|
||||
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="Tree_West_01" parent="FoliageRoot" instance=ExtResource("4_tree")]
|
||||
@@ -338,11 +408,94 @@ safety_risk = 0.02
|
||||
comfort_distance = 16.0
|
||||
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="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
|
||||
position = Vector3(0, 0, -8)
|
||||
|
||||
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
|
||||
position = Vector3(8, 0, -12)
|
||||
storage_id = &"village_woodpile"
|
||||
|
||||
[node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
|
||||
position = Vector3(-1.05, 1.1, 1.25)
|
||||
mesh = SubResource("Mesh_site_pole")
|
||||
|
||||
[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="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)
|
||||
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="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)
|
||||
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="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)
|
||||
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="DebugLabel" type="Label3D" parent="WorldObjects/ActivitySites/RestBench"]
|
||||
@@ -420,6 +607,14 @@ light_energy = 1.45
|
||||
shadow_enabled = true
|
||||
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="."]
|
||||
environment = SubResource("Environment_greybox")
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ shader = ExtResource("1_water_shader")
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_water_body"]
|
||||
material = SubResource("ShaderMaterial_water")
|
||||
size = Vector3(5, 0.08, 56)
|
||||
size = Vector3(8, 0.08, 58)
|
||||
|
||||
[node name="WaterBody" type="Node3D"]
|
||||
position = Vector3(25, 0.12, 0)
|
||||
position = Vector3(25, 0.08, 0)
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("Mesh_water_body")
|
||||
|
||||
@@ -7,10 +7,10 @@ shader = ExtResource("1_waterfall_shader")
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_waterfall_body"]
|
||||
material = SubResource("ShaderMaterial_waterfall")
|
||||
size = Vector3(5, 10, 0.5)
|
||||
size = Vector3(8, 12, 0.5)
|
||||
|
||||
[node name="WaterfallBody" type="Node3D"]
|
||||
position = Vector3(25, 5, -24)
|
||||
position = Vector3(25, 6, -24)
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("Mesh_waterfall_body")
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"]
|
||||
lifetime_randomness = 0.2
|
||||
lifetime_randomness = 0.35
|
||||
spread = 180.0
|
||||
gravity = Vector3(0, -0.5, 0)
|
||||
initial_velocity_min = 0.3
|
||||
initial_velocity_max = 0.8
|
||||
scale_min = 0.1
|
||||
scale_max = 1.5
|
||||
color = Color(0.85, 0.95, 0.95, 0.4)
|
||||
gravity = Vector3(0, -0.35, 0)
|
||||
initial_velocity_min = 0.25
|
||||
initial_velocity_max = 1.2
|
||||
scale_min = 0.08
|
||||
scale_max = 2.0
|
||||
color = Color(0.82, 0.94, 0.96, 0.35)
|
||||
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"]
|
||||
albedo_color = Color(0.85, 0.95, 0.95, 0.35)
|
||||
albedo_color = Color(0.82, 0.94, 0.96, 0.28)
|
||||
transparency = 1
|
||||
shading_mode = 0
|
||||
billboard_mode = 1
|
||||
@@ -21,14 +21,14 @@ cull_mode = 0
|
||||
|
||||
[sub_resource type="QuadMesh" id="Mesh_mist"]
|
||||
material = SubResource("Material_mist")
|
||||
size = Vector2(1.5, 1.5)
|
||||
size = Vector2(1.8, 1.8)
|
||||
|
||||
[node name="WaterfallMist" type="GPUParticles3D"]
|
||||
emitting = true
|
||||
amount = 48
|
||||
lifetime = 2.5
|
||||
amount = 72
|
||||
lifetime = 3.0
|
||||
one_shot = false
|
||||
preprocess = 1.0
|
||||
preprocess = 1.5
|
||||
speed_scale = 1.0
|
||||
local_coords = true
|
||||
draw_order = 0
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1,6 +1,7 @@
|
||||
extends Node
|
||||
|
||||
@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 world_environment: WorldEnvironment
|
||||
|
||||
@@ -35,13 +36,26 @@ const SUNRISE_LIGHT_COLOR := Color(1, 0.6, 0.45, 1)
|
||||
const SUNRISE_SUNSET_ENERGY := 0.8
|
||||
|
||||
var elapsed := 0.0
|
||||
var _simulation_node: Node
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
elapsed += delta
|
||||
func _ready() -> void:
|
||||
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
|
||||
if _simulation_node == null:
|
||||
elapsed = cycle_duration_seconds * initial_cycle
|
||||
_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)
|
||||
var cycle := elapsed / cycle_duration_seconds
|
||||
cycle = elapsed / cycle_duration_seconds
|
||||
_apply_light_rotation(cycle)
|
||||
_apply_environment(cycle)
|
||||
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
shader_type spatial;
|
||||
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_speed : hint_range(0.0, 5.0) = 1.2;
|
||||
uniform float wave_strength : hint_range(0.0, 0.5) = 0.08;
|
||||
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() {
|
||||
vec3 pos = VERTEX;
|
||||
float wave = sin(pos.z * 0.4 + TIME * wave_speed) * wave_strength;
|
||||
pos.y += wave;
|
||||
float wave_a = sin(pos.x * 0.35 + pos.z * 0.25 + TIME * wave_speed) * wave_strength;
|
||||
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;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
ALBEDO = vec3(0.12, 0.39, 0.55);
|
||||
METALLIC = 0.15;
|
||||
ROUGHNESS = 0.1;
|
||||
EMISSION = vec3(0.05, 0.19, 0.24) * 0.45;
|
||||
ALPHA = 0.72;
|
||||
vec3 view_dir = VIEW;
|
||||
vec3 normal = NORMAL;
|
||||
vec3 view_norm = normalize(view_dir);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
shader_type spatial;
|
||||
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() {
|
||||
}
|
||||
@@ -9,13 +14,37 @@ void vertex() {
|
||||
void fragment() {
|
||||
vec2 uv = UV;
|
||||
uv.y += TIME * scroll_speed;
|
||||
float pattern = fract(uv.y * 4.0);
|
||||
pattern = smoothstep(0.3, 0.7, pattern);
|
||||
pattern = mix(0.4, 1.0, pattern);
|
||||
vec3 base_color = mix(vec3(0.6, 0.85, 0.9), vec3(1.0, 1.0, 1.0), pattern);
|
||||
ALBEDO = base_color;
|
||||
ALPHA = mix(0.3, 0.7, pattern);
|
||||
EMISSION = vec3(0.36, 0.52, 0.54) * 0.35 * pattern;
|
||||
METALLIC = 0.0;
|
||||
ROUGHNESS = 0.2;
|
||||
|
||||
float band_a = fract(uv.y * 5.0 + sin(uv.x * 2.0) * 0.15);
|
||||
band_a = smoothstep(0.2, 0.55, band_a) * smoothstep(0.8, 0.45, band_a);
|
||||
|
||||
float band_b = fract(uv.y * 3.5 + cos(uv.x * 2.5 + TIME * 0.3) * 0.12);
|
||||
band_b = smoothstep(0.1, 0.5, band_b) * smoothstep(0.85, 0.4, band_b);
|
||||
|
||||
float foam_a = fract(uv.y * 4.0);
|
||||
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
|
||||
+21
-19
@@ -1,6 +1,6 @@
|
||||
extends Control
|
||||
|
||||
@export var day_night_cycle: Node
|
||||
@export var simulation_manager: Node
|
||||
|
||||
const SIZE_DIAL := 60.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)
|
||||
|
||||
var _last_fraction := -1.0
|
||||
var _last_color := Color.TRANSPARENT
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -44,29 +43,37 @@ func _draw() -> void:
|
||||
var tip_pos := center + tip_dir * RING_RADIUS
|
||||
draw_circle(tip_pos, GLOW_RADIUS, time_color.lightened(0.3))
|
||||
|
||||
var label := _phase_label(fraction)
|
||||
var font := ThemeDB.fallback_font
|
||||
if font == null:
|
||||
return
|
||||
|
||||
var time_label := _time_label(fraction)
|
||||
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_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:
|
||||
if day_night_cycle == null:
|
||||
return 0.0
|
||||
if simulation_manager != null:
|
||||
if "clock" in simulation_manager:
|
||||
return simulation_manager.clock.time_of_day()
|
||||
var day_night_cycle := get_node_or_null(day_night_cycle_fallback())
|
||||
if day_night_cycle != null:
|
||||
var elapsed: float = day_night_cycle.get("elapsed") if "elapsed" in day_night_cycle else 0.0
|
||||
var duration: float = (
|
||||
day_night_cycle.get("cycle_duration_seconds")
|
||||
if "cycle_duration_seconds" in day_night_cycle
|
||||
else 240.0
|
||||
)
|
||||
if duration <= 0.0:
|
||||
return 0.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:
|
||||
@@ -81,13 +88,8 @@ func _time_color(fraction: float) -> Color:
|
||||
return COLOR_DUSK.lerp(COLOR_NIGHT, smoothstep(0.85, 1.0, fraction))
|
||||
|
||||
|
||||
func _phase_label(fraction: float) -> String:
|
||||
if fraction < 0.1:
|
||||
return "Night"
|
||||
if fraction < 0.2:
|
||||
return "Dawn"
|
||||
if fraction < 0.65:
|
||||
return "Day"
|
||||
if fraction < 0.8:
|
||||
return "Dusk"
|
||||
return "Night"
|
||||
func _time_label(fraction: float) -> String:
|
||||
var total_minutes := int(fraction * 24.0 * 60.0)
|
||||
var hour := total_minutes / 60
|
||||
var minute := total_minutes % 60
|
||||
return "%02d:%02d" % [hour, minute]
|
||||
|
||||
+82
-6
@@ -27,6 +27,8 @@ func _ready() -> void:
|
||||
push_error("VillageUI: SimulationManager has no npc_decision_recorded signal")
|
||||
if simulation_manager.has_signal("state_restored"):
|
||||
simulation_manager.state_restored.connect(_on_state_restored)
|
||||
if simulation_manager.has_signal("speed_changed"):
|
||||
simulation_manager.speed_changed.connect(_on_speed_changed)
|
||||
|
||||
if "village" in simulation_manager:
|
||||
_on_village_changed(simulation_manager.village)
|
||||
@@ -52,12 +54,33 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
|
||||
|
||||
func _on_village_changed(village: SimVillage) -> void:
|
||||
var name_map := {}
|
||||
for npc in simulation_manager.npcs:
|
||||
name_map[npc.id] = npc.npc_name
|
||||
var event_lines: Array[String] = []
|
||||
for event in simulation_manager.get_recent_events(3):
|
||||
event_lines.append(event.description(name_map))
|
||||
var event_text := "\n".join(event_lines)
|
||||
if event_text.is_empty():
|
||||
event_text = ""
|
||||
else:
|
||||
event_text = "\n" + event_text
|
||||
var speed_text := ""
|
||||
if simulation_manager.has_method("get_current_speed"):
|
||||
speed_text = simulation_manager.get_current_speed()
|
||||
var rates: Dictionary = simulation_manager.get_resource_rates()
|
||||
var food_rate := ""
|
||||
if rates["food_per_day"] > 0.01:
|
||||
food_rate = " (−%.0f/d)" % rates["food_per_day"]
|
||||
var wood_rate := ""
|
||||
if rates["wood_per_day"] > 0.01:
|
||||
wood_rate = " (−%.0f/d)" % rates["wood_per_day"]
|
||||
village_stats_label.text = (
|
||||
"""
|
||||
Village
|
||||
Village [%s]
|
||||
|
||||
Food: %s
|
||||
Wood: %s
|
||||
Food: %s%s
|
||||
Wood: %s%s
|
||||
Safety: %s
|
||||
Knowledge: %s
|
||||
Starving: %s
|
||||
@@ -65,16 +88,21 @@ func _on_village_changed(village: SimVillage) -> void:
|
||||
Food Mod: %.2f
|
||||
Safety Mod: %.2f
|
||||
Knowledge Mod: %.2f
|
||||
%s
|
||||
"""
|
||||
% [
|
||||
speed_text,
|
||||
round(village.food),
|
||||
food_rate,
|
||||
round(village.wood),
|
||||
wood_rate,
|
||||
round(village.safety),
|
||||
round(village.knowledge),
|
||||
simulation_manager.get_starving_count(),
|
||||
village.food_modifier,
|
||||
village.safety_modifier,
|
||||
village.knowledge_modifier
|
||||
village.knowledge_modifier,
|
||||
event_text
|
||||
]
|
||||
)
|
||||
|
||||
@@ -88,6 +116,11 @@ func _on_state_restored() -> void:
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
func _on_speed_changed(_multiplier: float) -> void:
|
||||
if simulation_manager != null and "village" in simulation_manager:
|
||||
_on_village_changed(simulation_manager.village)
|
||||
|
||||
|
||||
func _refresh_npc_inspector() -> void:
|
||||
if not debug_overlay_visible:
|
||||
return
|
||||
@@ -118,13 +151,17 @@ func _refresh_npc_inspector() -> void:
|
||||
)
|
||||
if not score_rows.is_empty():
|
||||
score_text = "\n\nUtility\n" + "\n".join(score_rows)
|
||||
var event_text := _build_event_history(npc)
|
||||
var housemate_text := _build_housemate_display(npc)
|
||||
npc_inspector_label.text = (
|
||||
"%s · %s\n"
|
||||
+ "Task: %s (%s)\n"
|
||||
+ "Destination: %s\n"
|
||||
+ "Hunger: %.0f Energy: %.0f\n"
|
||||
+ "Carrying food: %.0f\n\n"
|
||||
+ "Carrying food: %.0f wood: %.0f\n"
|
||||
+ "%s\n"
|
||||
+ "Why\n%s%s\n\n"
|
||||
+ "Recent\n%s\n\n"
|
||||
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
|
||||
) % [
|
||||
npc.npc_name,
|
||||
@@ -135,8 +172,11 @@ func _refresh_npc_inspector() -> void:
|
||||
npc.hunger,
|
||||
npc.energy,
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
||||
housemate_text,
|
||||
reason_text,
|
||||
score_text
|
||||
score_text,
|
||||
event_text
|
||||
]
|
||||
|
||||
|
||||
@@ -156,3 +196,39 @@ func set_debug_overlay_visible(is_visible: bool) -> void:
|
||||
visible = is_visible
|
||||
if is_visible:
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
func _build_event_history(npc: SimNPC) -> String:
|
||||
if not simulation_manager.has_method("get_npc_events"):
|
||||
return ""
|
||||
var events: Array = simulation_manager.get_npc_events(npc.id, 5)
|
||||
if events.is_empty():
|
||||
return "No recorded events yet"
|
||||
var name_map := {}
|
||||
for other_npc in simulation_manager.npcs:
|
||||
name_map[other_npc.id] = other_npc.npc_name
|
||||
var lines: Array[String] = []
|
||||
for event in events:
|
||||
lines.append(event.description(name_map))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
func _build_housemate_display(npc: SimNPC) -> String:
|
||||
if npc.familiarity.is_empty():
|
||||
return ""
|
||||
var highest_id: int = -1
|
||||
var highest_score := -1.0
|
||||
for key in npc.familiarity:
|
||||
var other_id: int = int(key)
|
||||
var score: float = float(npc.familiarity[key])
|
||||
if score > highest_score:
|
||||
highest_score = score
|
||||
highest_id = other_id
|
||||
if highest_id < 0:
|
||||
return ""
|
||||
var other_name := "Someone"
|
||||
for other_npc in simulation_manager.npcs:
|
||||
if other_npc.id == highest_id:
|
||||
other_name = other_npc.npc_name
|
||||
break
|
||||
return "Familiar: %s (%.0f%%)" % [other_name, highest_score * 100.0]
|
||||
|
||||
@@ -36,6 +36,10 @@ func initialize_world_view() -> void:
|
||||
simulation_manager.npc_inventory_changed.connect(_on_npc_inventory_changed)
|
||||
else:
|
||||
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"):
|
||||
simulation_manager.state_restored.connect(_on_simulation_state_restored)
|
||||
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:
|
||||
if item_id != SimulationIds.RESOURCE_FOOD:
|
||||
return
|
||||
var visual = active_npc_visuals.get(npc.id)
|
||||
if visual == null:
|
||||
return
|
||||
if item_id == SimulationIds.RESOURCE_FOOD:
|
||||
if visual.has_method("set_carried_food_visible"):
|
||||
visual.set_carried_food_visible(amount > 0.0)
|
||||
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:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user