Compare commits
88 Commits
595233e5df
..
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 | |||
| d2eca15ecd | |||
| 41b515f474 | |||
| 1c389ec23d | |||
| 9d87372a15 | |||
| b74573fe0e | |||
| 4bfc09d5f6 | |||
| b0bad6c467 | |||
| 72d1cfc15d | |||
| 036c9d5141 | |||
| 4c090f1635 | |||
| e5bd93d705 | |||
| 33b9c1bdfb | |||
| 3517762864 | |||
| 0aede053cb | |||
| 097cfaeaca | |||
| 3379d08b93 | |||
| 734fe12098 | |||
| 58d3573965 | |||
| d883148132 | |||
| 7edf9acd57 | |||
| ebbd225628 | |||
| ac0282716c | |||
| fde2101690 | |||
| ed0bc3f37f | |||
| 4463e524aa | |||
| 28a2e42c41 | |||
| 22613a445e | |||
| 6c11dc5dd6 | |||
| f12fb6ad78 | |||
| 5851c0362a | |||
| 40014411fb | |||
| 08b7bee0e5 | |||
| 2fcf641152 | |||
| f9601a67d1 | |||
| ebf70a711a | |||
| 6dbb395bd6 | |||
| 3ea8f02d55 | |||
| a8ae331f51 | |||
| 4f9dc842fd | |||
| 4673f0698d | |||
| 81df7b5939 | |||
| f816f3976a | |||
| 12f9ba616f | |||
| 363620b731 | |||
| f83d2c7704 | |||
| 30cac28fc0 | |||
| c690937afa | |||
| 37610242bf | |||
| b50fae671a | |||
| 556d0fdc26 | |||
| 83fc258afc | |||
| 0900022558 | |||
| 326df51cbe | |||
| f5e7326d49 | |||
| b8752f8835 | |||
| 1837bc0d2d | |||
| 41bfc8d90a | |||
| c6033b63fb | |||
| 0dfaa06f9c | |||
| 8049c52e0f | |||
| 9cd38a1adb | |||
| e30d208c3f |
@@ -2,3 +2,22 @@ root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.gd]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
|
||||
[*.{json,yaml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{bat,cmd}]
|
||||
end_of_line = crlf
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
+68
-1
@@ -1,7 +1,74 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
# Use deterministic line endings on every platform.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Godot source and serialized resources
|
||||
*.gd text eol=lf
|
||||
*.gd.uid text eol=lf
|
||||
*.gdextension text eol=lf
|
||||
*.gdnlib text eol=lf
|
||||
*.tscn text eol=lf
|
||||
*.tres text eol=lf
|
||||
*.godot text eol=lf
|
||||
*.import text eol=lf
|
||||
*.shader text eol=lf
|
||||
*.gdshader text eol=lf
|
||||
*.gdshaderinc text eol=lf
|
||||
|
||||
# Common source, configuration, and documentation formats
|
||||
*.cs text eol=lf
|
||||
*.cfg text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.ps1 text eol=lf
|
||||
|
||||
# Native Windows command scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# SVG is text; the remaining asset formats must never receive EOL conversion.
|
||||
*.svg text eol=lf
|
||||
*.bmp binary
|
||||
*.dds binary
|
||||
*.exr binary
|
||||
*.gif binary
|
||||
*.hdr binary
|
||||
*.ico binary
|
||||
*.jpeg binary
|
||||
*.jpg binary
|
||||
*.ktx binary
|
||||
*.png binary
|
||||
*.tga binary
|
||||
*.webp binary
|
||||
*.blend binary
|
||||
*.fbx binary
|
||||
*.glb binary
|
||||
*.3ds binary
|
||||
*.dae text eol=lf
|
||||
*.gltf text eol=lf
|
||||
*.mtl text eol=lf
|
||||
*.obj text eol=lf
|
||||
*.mp3 binary
|
||||
*.ogg binary
|
||||
*.wav binary
|
||||
*.flac binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.avi binary
|
||||
*.mov binary
|
||||
*.otf binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.7z binary
|
||||
*.gz binary
|
||||
*.rar binary
|
||||
*.tar binary
|
||||
*.zip binary
|
||||
*.pck binary
|
||||
|
||||
+66
-28
@@ -1,44 +1,82 @@
|
||||
# Godot import cache
|
||||
.import/
|
||||
|
||||
# Godot editor generated files
|
||||
# Godot 4 editor data and imported asset cache
|
||||
.godot/
|
||||
|
||||
# Export templates and builds
|
||||
# Godot 3 imported asset cache
|
||||
.import/
|
||||
|
||||
# Local export output (keep export_presets.cfg tracked)
|
||||
/export/
|
||||
/exports/
|
||||
/build/
|
||||
/builds/
|
||||
/dist/
|
||||
|
||||
# Mono / C#
|
||||
# Godot C# / Mono generated files
|
||||
.mono/
|
||||
data_*/
|
||||
mono_crash.*
|
||||
|
||||
# Visual Studio / Rider
|
||||
.vs/
|
||||
.idea/
|
||||
*.csproj
|
||||
*.sln
|
||||
*.user
|
||||
*.DotSettings.user
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Linux
|
||||
.directory
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs
|
||||
# Godot and application crash/log output
|
||||
crash_handler*
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
# Local environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Godot crash reports
|
||||
crash_handler*
|
||||
# Editors and IDEs
|
||||
.idea/
|
||||
.fleet/
|
||||
.vs/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.DotSettings.user
|
||||
|
||||
# Editor swap, backup, and temporary files
|
||||
*.bak
|
||||
*.orig
|
||||
*.rej
|
||||
*.swp
|
||||
*.swo
|
||||
*.temp
|
||||
*.tmp
|
||||
*~
|
||||
~*.dll
|
||||
.#*
|
||||
\#*\#
|
||||
|
||||
# macOS metadata
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon?
|
||||
._*
|
||||
.DocumentRevisions-V100/
|
||||
.Spotlight-V100/
|
||||
.TemporaryItems/
|
||||
.Trashes/
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Windows metadata
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.stackdump
|
||||
|
||||
# Local quality gate logs
|
||||
logs/quality/
|
||||
|
||||
# Linux desktop and trash metadata
|
||||
.directory
|
||||
.Trash-*
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Agent working guide for The Steward
|
||||
|
||||
This repository is a Godot 4.7 simulation prototype. Treat it as a living
|
||||
systems project, not a content dump. The expected working style is:
|
||||
|
||||
## Default development loop
|
||||
|
||||
1. Read the relevant roadmap/docs before changing code.
|
||||
- Start with `docs/README.md`.
|
||||
- Use `docs/LEARNING_ROADMAP.md` for the current next item.
|
||||
- Use focused plans such as `docs/RESOURCE_NODE_MIGRATION.md` only within
|
||||
their stated scope.
|
||||
2. Inspect the current code and tests before assuming roadmap status is still
|
||||
accurate. The user often changes things in parallel.
|
||||
3. Implement the smallest useful vertical slice.
|
||||
- Prefer functional systems progress over broad polish.
|
||||
- Avoid deep rabbit holes unless the current slice needs them.
|
||||
- Keep Jajce visual/design work bounded; prioritize simulation behavior,
|
||||
validation, WorldEnvironment ambience, and readable presentation.
|
||||
4. Validate with the local quality gate.
|
||||
5. Update the smallest relevant docs after completing a phase or decision.
|
||||
6. Commit with a conventional commit message.
|
||||
|
||||
## Validation
|
||||
|
||||
Only Godot 4.7 matters for this project.
|
||||
|
||||
Use the project quality gate from PowerShell:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1
|
||||
```
|
||||
|
||||
For changed-file quick checks:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\tools\quality.ps1 -Changed
|
||||
```
|
||||
|
||||
The quality scripts isolate Godot's user profile under `logs/quality/godot_profile`
|
||||
so headless Godot 4.7 can run without crashing on blocked Windows app-data paths.
|
||||
Do not remove that behavior.
|
||||
|
||||
Useful extra checks:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
If touching `main.tscn` or runtime wiring, also do a direct Godot 4.7 headless
|
||||
boot when practical.
|
||||
|
||||
## Architecture direction
|
||||
|
||||
Preserve the simulation/presentation boundary.
|
||||
|
||||
- Simulation state owns authority: resources, storage, NPC inventory, events,
|
||||
task state, target IDs, positions, RNG streams, and save records.
|
||||
- World nodes are presentation and interaction geometry. They may register or
|
||||
bind state, but they should not become the only owner of persistent facts.
|
||||
- NPCs store stable IDs, not `NodePath`s or node references.
|
||||
- Target discovery should go through `ActiveWorldAdapter` and
|
||||
`ActionTargetResolver`.
|
||||
- Visual movement belongs to `WorldViewManager`/`NpcVisual`; decision and task
|
||||
execution belong to the simulation systems.
|
||||
|
||||
When adding a system, first prove the contract with one real gameplay use case.
|
||||
Do not extract generic frameworks before multiple real consumers justify them.
|
||||
|
||||
## Resource and target rules
|
||||
|
||||
Do not reintroduce abstract food/wood task-zone fallbacks.
|
||||
|
||||
Current resource gathering should use finite `ResourceNode` instances:
|
||||
|
||||
- food: berries, animal camps, village stock, future farms/crops;
|
||||
- wood: trees, wood piles, future forestry contexts.
|
||||
|
||||
Resource additions should preserve:
|
||||
|
||||
- stable unique IDs;
|
||||
- food/wood coverage;
|
||||
- meaningful placement context;
|
||||
- reachable interaction points;
|
||||
- `safety_risk`, `comfort_distance`, and `discovery_priority` metadata;
|
||||
- clear player interaction ranges so nearby storage/activity/resource targets
|
||||
do not overlap accidentally.
|
||||
|
||||
Storage uses `StorageNode`. Rest, study, and patrol use `ActivitySite`. Do not
|
||||
turn these back into generic marker zones.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
Add or update headless scenarios when a change affects:
|
||||
|
||||
- resource conservation;
|
||||
- target selection or reservations;
|
||||
- save/load or schema behavior;
|
||||
- navigation/reachability assumptions;
|
||||
- player/NPC parity;
|
||||
- deterministic continuation;
|
||||
- UI/debug state that represents real simulation facts.
|
||||
|
||||
Keep tests deterministic. Prefer fixed seeds and stable IDs.
|
||||
|
||||
## Documentation expectations
|
||||
|
||||
Update docs when the implemented behavior changes the roadmap, architecture, or
|
||||
current contract. Keep updates local:
|
||||
|
||||
- `docs/LEARNING_ROADMAP.md` for next-item sequencing and milestone status;
|
||||
- `docs/RESOURCE_NODE_MIGRATION.md` for resource-target migration status;
|
||||
- `docs/SIMULATION_STATE_SCHEMA.md` for serialized state changes;
|
||||
- `docs/SIMULATION_DEFINITIONS.md` for action/profession/ID contracts;
|
||||
- `docs/BUILD_IN_PUBLIC_PLAN.md` for Jajce/demo/readability slices;
|
||||
- `docs/decisions/` only for durable architectural decisions.
|
||||
|
||||
Do not duplicate full status tables across many docs. If docs and code differ,
|
||||
trust code/tests first, then update docs.
|
||||
|
||||
## Git expectations
|
||||
|
||||
Preserve user changes. Check `git status --short` before editing and before
|
||||
committing. Do not use destructive cleanup commands unless explicitly asked.
|
||||
|
||||
Use conventional commits, for example:
|
||||
|
||||
- `feat: validate expanded resource discovery`
|
||||
- `fix: stabilize godot 4.7 headless validation`
|
||||
- `docs: capture agent workflow`
|
||||
|
||||
Commit only after validation relevant to the change has passed.
|
||||
|
||||
## Product taste
|
||||
|
||||
The project is aiming for a readable, magical simulation garden that can grow
|
||||
into a larger systemic game. Prefer honest visible cause-and-effect:
|
||||
|
||||
- NPCs should walk to real resources, storage, and activity sites.
|
||||
- UI/debug overlays should explain actual simulation state.
|
||||
- Beauty work should represent real state rather than fake activity.
|
||||
- Each slice should leave the project easier to reason about than before.
|
||||
+11
-5
@@ -24,8 +24,10 @@ extends Terrain3D
|
||||
|
||||
@export var simple_grass_textured: MultiMeshInstance3D
|
||||
@export var assign_mesh_id: int
|
||||
@export var import: bool = false : set = import_sgt
|
||||
@export var clear_instances: bool = false : set = clear_multimeshes
|
||||
@export var import: bool = false:
|
||||
set = import_sgt
|
||||
@export var clear_instances: bool = false:
|
||||
set = clear_multimeshes
|
||||
|
||||
|
||||
func clear_multimeshes(value: bool) -> void:
|
||||
@@ -35,8 +37,12 @@ func clear_multimeshes(value: bool) -> void:
|
||||
func import_sgt(value: bool) -> void:
|
||||
var sgt_mm: MultiMesh = simple_grass_textured.multimesh
|
||||
var global_xform: Transform3D = simple_grass_textured.global_transform
|
||||
print("Starting to import %d instances from SimpleGrassTextured using mesh id %d" % [ sgt_mm.instance_count, assign_mesh_id])
|
||||
print(
|
||||
(
|
||||
"Starting to import %d instances from SimpleGrassTextured using mesh id %d"
|
||||
% [sgt_mm.instance_count, assign_mesh_id]
|
||||
)
|
||||
)
|
||||
var time: int = Time.get_ticks_msec()
|
||||
get_instancer().add_multimesh(assign_mesh_id, sgt_mm, simple_grass_textured.global_transform)
|
||||
print("Import complete in %.2f seconds" % [ float(Time.get_ticks_msec() - time)/1000. ])
|
||||
|
||||
print("Import complete in %.2f seconds" % [float(Time.get_ticks_msec() - time) / 1000.])
|
||||
|
||||
+95
-95
@@ -26,112 +26,112 @@
|
||||
#
|
||||
#
|
||||
#func _init() -> void:
|
||||
#display_name = "Project On Terrain3D"
|
||||
#category = "Edit"
|
||||
#can_restrict_height = false
|
||||
#global_reference_frame_available = true
|
||||
#local_reference_frame_available = true
|
||||
#individual_instances_reference_frame_available = true
|
||||
#use_global_space_by_default()
|
||||
#display_name = "Project On Terrain3D"
|
||||
#category = "Edit"
|
||||
#can_restrict_height = false
|
||||
#global_reference_frame_available = true
|
||||
#local_reference_frame_available = true
|
||||
#individual_instances_reference_frame_available = true
|
||||
#use_global_space_by_default()
|
||||
#
|
||||
#documentation.add_paragraph(
|
||||
#"This is a modified version of `Project on Colliders` that queries Terrain3D
|
||||
#for heights without using collision. It constrains placement by slope or texture.
|
||||
#documentation.add_paragraph(
|
||||
#"This is a modified version of `Project on Colliders` that queries Terrain3D
|
||||
#for heights without using collision. It constrains placement by slope or texture.
|
||||
#
|
||||
#This modifier must have terrain_node set to a Terrain3D node.")
|
||||
#This modifier must have terrain_node set to a Terrain3D node.")
|
||||
#
|
||||
#var p := documentation.add_parameter("Terrain Node")
|
||||
#p.set_type("NodePath")
|
||||
#p.set_description("Set your Terrain3D node.")
|
||||
#
|
||||
#p = documentation.add_parameter("Align with collision normal")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"Rotate the transform to align it with the collision normal in case
|
||||
#the ray cast hit a collider.")
|
||||
#
|
||||
#p = documentation.add_parameter("Enable Texture Filtering")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"If enabled, objects will only be placed based on the ground texture specified.")
|
||||
#
|
||||
#p = documentation.add_parameter("Target Texture ID")
|
||||
#p.set_type("int")
|
||||
#p.set_description(
|
||||
#"The ID of the texture to place objects on (0-31). Objects will only be placed on this texture.")
|
||||
#
|
||||
#p = documentation.add_parameter("Not Target Texture")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"If true, objects will be placed on all textures EXCEPT the target texture.")
|
||||
#
|
||||
#p = documentation.add_parameter("Texture Threshold")
|
||||
#p.set_type("float")
|
||||
#p.set_description("The blend value required for placement on the texture.")
|
||||
#var p := documentation.add_parameter("Terrain Node")
|
||||
#p.set_type("NodePath")
|
||||
#p.set_description("Set your Terrain3D node.")
|
||||
#
|
||||
#p = documentation.add_parameter("Align with collision normal")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"Rotate the transform to align it with the collision normal in case
|
||||
#the ray cast hit a collider.")
|
||||
#
|
||||
#p = documentation.add_parameter("Enable Texture Filtering")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"If enabled, objects will only be placed based on the ground texture specified.")
|
||||
#
|
||||
#p = documentation.add_parameter("Target Texture ID")
|
||||
#p.set_type("int")
|
||||
#p.set_description(
|
||||
#"The ID of the texture to place objects on (0-31). Objects will only be placed on this texture.")
|
||||
#
|
||||
#p = documentation.add_parameter("Not Target Texture")
|
||||
#p.set_type("bool")
|
||||
#p.set_description(
|
||||
#"If true, objects will be placed on all textures EXCEPT the target texture.")
|
||||
#
|
||||
#p = documentation.add_parameter("Texture Threshold")
|
||||
#p.set_type("float")
|
||||
#p.set_description("The blend value required for placement on the texture.")
|
||||
#
|
||||
#
|
||||
#func _process_transforms(transforms, domain, _seed) -> void:
|
||||
#if transforms.is_empty():
|
||||
#return
|
||||
#if transforms.is_empty():
|
||||
#return
|
||||
#
|
||||
#if terrain_node:
|
||||
#_terrain = domain.get_root().get_node_or_null(terrain_node)
|
||||
#if terrain_node:
|
||||
#_terrain = domain.get_root().get_node_or_null(terrain_node)
|
||||
#
|
||||
#if not _terrain:
|
||||
#warning += """No Terrain3D node found"""
|
||||
#return
|
||||
#if not _terrain:
|
||||
#warning += """No Terrain3D node found"""
|
||||
#return
|
||||
#
|
||||
#if not _terrain.data:
|
||||
#warning += """Terrain3DData is not initialized"""
|
||||
#return
|
||||
#if not _terrain.data:
|
||||
#warning += """Terrain3DData is not initialized"""
|
||||
#return
|
||||
#
|
||||
## Review transforms
|
||||
#var gt: Transform3D = domain.get_global_transform()
|
||||
#var gt_inverse := gt.affine_inverse()
|
||||
#var new_transforms_array: Array[Transform3D] = []
|
||||
#var remapped_max_slope: float = remap(max_slope, 0.0, 90.0, 0.0, 1.0)
|
||||
#for i in transforms.list.size():
|
||||
#var t: Transform3D = transforms.list[i]
|
||||
#
|
||||
#var location: Vector3 = (gt * t).origin
|
||||
#var height: float = _terrain.data.get_height(location)
|
||||
#if is_nan(height):
|
||||
#continue
|
||||
#
|
||||
#var normal: Vector3 = _terrain.data.get_normal(location)
|
||||
#if not abs(Vector3.UP.dot(normal)) >= (1.0 - remapped_max_slope):
|
||||
#continue
|
||||
#
|
||||
#if enable_texture_filtering:
|
||||
#var texture_info: Vector3 = _terrain.data.get_texture_id(location)
|
||||
#var base_id: int = int(texture_info.x)
|
||||
#var overlay_id: int = int(texture_info.y)
|
||||
#var blend_value: float = texture_info.z
|
||||
## Skip if overlay or blend != target texture, unless inverted
|
||||
#if ((overlay_id != target_texture_id or blend_value < texture_threshold) and \
|
||||
#(base_id != target_texture_id or blend_value >= texture_threshold)) != not_target_texture:
|
||||
#continue
|
||||
## Review transforms
|
||||
#var gt: Transform3D = domain.get_global_transform()
|
||||
#var gt_inverse := gt.affine_inverse()
|
||||
#var new_transforms_array: Array[Transform3D] = []
|
||||
#var remapped_max_slope: float = remap(max_slope, 0.0, 90.0, 0.0, 1.0)
|
||||
#for i in transforms.list.size():
|
||||
#var t: Transform3D = transforms.list[i]
|
||||
#
|
||||
#if align_with_collision_normal and not is_nan(normal.x):
|
||||
#t.basis.y = normal
|
||||
#t.basis.x = -t.basis.z.cross(normal)
|
||||
#t.basis = t.basis.orthonormalized()
|
||||
#var location: Vector3 = (gt * t).origin
|
||||
#var height: float = _terrain.data.get_height(location)
|
||||
#if is_nan(height):
|
||||
#continue
|
||||
#
|
||||
#t.origin.y = height - gt.origin.y
|
||||
#new_transforms_array.push_back(t)
|
||||
#var normal: Vector3 = _terrain.data.get_normal(location)
|
||||
#if not abs(Vector3.UP.dot(normal)) >= (1.0 - remapped_max_slope):
|
||||
#continue
|
||||
#
|
||||
#transforms.list.clear()
|
||||
#transforms.list.append_array(new_transforms_array)
|
||||
#if enable_texture_filtering:
|
||||
#var texture_info: Vector3 = _terrain.data.get_texture_id(location)
|
||||
#var base_id: int = int(texture_info.x)
|
||||
#var overlay_id: int = int(texture_info.y)
|
||||
#var blend_value: float = texture_info.z
|
||||
## Skip if overlay or blend != target texture, unless inverted
|
||||
#if ((overlay_id != target_texture_id or blend_value < texture_threshold) and \
|
||||
#(base_id != target_texture_id or blend_value >= texture_threshold)) != not_target_texture:
|
||||
#continue
|
||||
#
|
||||
#if transforms.is_empty():
|
||||
#warning += """All transforms have been removed. Possible reasons include: \n"""
|
||||
#if enable_texture_filtering:
|
||||
#warning += """+ No matching texture found at any position.
|
||||
#+ Texture threshold may be too high.
|
||||
#"""
|
||||
#warning += """+ No collider is close enough to the shapes.
|
||||
#+ Ray length is too short.
|
||||
#+ Ray direction is incorrect.
|
||||
#+ Collision mask is not set properly.
|
||||
#+ Max slope is too low.
|
||||
#"""
|
||||
#if align_with_collision_normal and not is_nan(normal.x):
|
||||
#t.basis.y = normal
|
||||
#t.basis.x = -t.basis.z.cross(normal)
|
||||
#t.basis = t.basis.orthonormalized()
|
||||
#
|
||||
#t.origin.y = height - gt.origin.y
|
||||
#new_transforms_array.push_back(t)
|
||||
#
|
||||
#transforms.list.clear()
|
||||
#transforms.list.append_array(new_transforms_array)
|
||||
#
|
||||
#if transforms.is_empty():
|
||||
#warning += """All transforms have been removed. Possible reasons include: \n"""
|
||||
#if enable_texture_filtering:
|
||||
#warning += """+ No matching texture found at any position.
|
||||
#+ Texture threshold may be too high.
|
||||
#"""
|
||||
#warning += """+ No collider is close enough to the shapes.
|
||||
#+ Ray length is too short.
|
||||
#+ Ray direction is incorrect.
|
||||
#+ Collision mask is not set properly.
|
||||
#+ Max slope is too low.
|
||||
#"""
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
|
||||
#region settings
|
||||
## Auto set if attached as a child of a Terrain3D node
|
||||
@export var terrain: Terrain3D:
|
||||
@@ -15,7 +14,6 @@ extends Node3D
|
||||
terrain = value
|
||||
_create_grid()
|
||||
|
||||
|
||||
## Distance between instances
|
||||
@export_range(0.125, 2.0, 0.015625) var instance_spacing: float = 0.5:
|
||||
set(value):
|
||||
@@ -24,7 +22,6 @@ extends Node3D
|
||||
amount = rows * rows
|
||||
_set_offsets()
|
||||
|
||||
|
||||
## Width of an individual cell of the grid
|
||||
@export_range(8.0, 256.0, 1.0) var cell_width: float = 32.0:
|
||||
set(value):
|
||||
@@ -44,7 +41,6 @@ extends Node3D
|
||||
p.custom_aabb = aabb
|
||||
_set_offsets()
|
||||
|
||||
|
||||
## Grid width. Must be odd.
|
||||
## Higher values cull slightly better, draw further out.
|
||||
@export_range(1, 15, 2) var grid_width: int = 9:
|
||||
@@ -54,7 +50,6 @@ extends Node3D
|
||||
min_draw_distance = 1.0
|
||||
_create_grid()
|
||||
|
||||
|
||||
@export_storage var rows: int = 1
|
||||
|
||||
@export_storage var amount: int = 1:
|
||||
@@ -65,7 +60,6 @@ extends Node3D
|
||||
for p in particle_nodes:
|
||||
p.amount = amount
|
||||
|
||||
|
||||
@export_range(1, 256, 1) var process_fixed_fps: int = 30:
|
||||
set(value):
|
||||
process_fixed_fps = maxi(value, 1)
|
||||
@@ -73,7 +67,6 @@ extends Node3D
|
||||
p.fixed_fps = process_fixed_fps
|
||||
p.preprocess = 1.0 / float(process_fixed_fps)
|
||||
|
||||
|
||||
## Access to process material parameters
|
||||
@export var process_material: ShaderMaterial
|
||||
|
||||
@@ -81,23 +74,21 @@ extends Node3D
|
||||
@export var mesh: Mesh
|
||||
|
||||
@export var shadow_mode: GeometryInstance3D.ShadowCastingSetting = (
|
||||
GeometryInstance3D.ShadowCastingSetting.SHADOW_CASTING_SETTING_ON):
|
||||
GeometryInstance3D.ShadowCastingSetting.SHADOW_CASTING_SETTING_ON
|
||||
):
|
||||
set(value):
|
||||
shadow_mode = value
|
||||
for p in particle_nodes:
|
||||
p.cast_shadow = value
|
||||
|
||||
|
||||
## Override material for the particle mesh
|
||||
@export_custom(
|
||||
PROPERTY_HINT_RESOURCE_TYPE,
|
||||
"BaseMaterial3D,ShaderMaterial") var mesh_material_override: Material:
|
||||
@export_custom(PROPERTY_HINT_RESOURCE_TYPE, "BaseMaterial3D,ShaderMaterial")
|
||||
var mesh_material_override: Material:
|
||||
set(value):
|
||||
mesh_material_override = value
|
||||
for p in particle_nodes:
|
||||
p.material_override = mesh_material_override
|
||||
|
||||
|
||||
@export_group("Info")
|
||||
## The minimum distance that particles will be drawn upto
|
||||
## If using fade out effects like pixel alpha this is the limit to use.
|
||||
@@ -105,7 +96,6 @@ extends Node3D
|
||||
set(value):
|
||||
min_draw_distance = float(cell_width * grid_width) * 0.5
|
||||
|
||||
|
||||
## Displays current total particle count based on Cell Width and Instance Spacing
|
||||
@export var particle_count: int = 1:
|
||||
set(value):
|
||||
@@ -113,7 +103,6 @@ extends Node3D
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
var offsets: Array[Vector3]
|
||||
var last_pos: Vector3 = Vector3.ZERO
|
||||
var particle_nodes: Array[GPUParticles3D]
|
||||
@@ -139,7 +128,9 @@ func _physics_process(delta: float) -> void:
|
||||
if last_pos.distance_squared_to(camera.global_position) > 1.0:
|
||||
var pos: Vector3 = camera.global_position.snapped(Vector3.ONE)
|
||||
_position_grid(pos)
|
||||
RenderingServer.material_set_param(process_material.get_rid(), "camera_position", pos )
|
||||
RenderingServer.material_set_param(
|
||||
process_material.get_rid(), "camera_position", pos
|
||||
)
|
||||
last_pos = camera.global_position
|
||||
_update_process_parameters()
|
||||
else:
|
||||
@@ -180,7 +171,7 @@ func _create_grid() -> void:
|
||||
if mesh_material_override:
|
||||
particle_node.material_override = mesh_material_override
|
||||
particle_node.use_fixed_seed = true
|
||||
if (x > -half_grid and z > -half_grid): # Use the same seed across all nodes
|
||||
if x > -half_grid and z > -half_grid: # Use the same seed across all nodes
|
||||
particle_node.seed = particle_nodes[0].seed
|
||||
self.add_child(particle_node)
|
||||
particle_node.emitting = true
|
||||
@@ -194,9 +185,7 @@ func _set_offsets() -> void:
|
||||
for x in range(-half_grid, half_grid + 1):
|
||||
for z in range(-half_grid, half_grid + 1):
|
||||
var offset := Vector3(
|
||||
float(x * rows) * instance_spacing,
|
||||
0.0,
|
||||
float(z * rows) * instance_spacing
|
||||
float(x * rows) * instance_spacing, 0.0, float(z * rows) * instance_spacing
|
||||
)
|
||||
offsets.append(offset)
|
||||
|
||||
@@ -221,17 +210,35 @@ func _update_process_parameters() -> void:
|
||||
if process_material:
|
||||
var process_rid: RID = process_material.get_rid()
|
||||
if terrain and process_rid.is_valid():
|
||||
RenderingServer.material_set_param(process_rid, "_background_mode", terrain.material.world_background)
|
||||
RenderingServer.material_set_param(process_rid, "_vertex_spacing", terrain.vertex_spacing)
|
||||
RenderingServer.material_set_param(process_rid, "_vertex_density", 1.0 / terrain.vertex_spacing)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_background_mode", terrain.material.world_background
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_vertex_spacing", terrain.vertex_spacing
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_vertex_density", 1.0 / terrain.vertex_spacing
|
||||
)
|
||||
RenderingServer.material_set_param(process_rid, "_region_size", terrain.region_size)
|
||||
RenderingServer.material_set_param(process_rid, "_region_texel_size", 1.0 / terrain.region_size)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_region_texel_size", 1.0 / terrain.region_size
|
||||
)
|
||||
RenderingServer.material_set_param(process_rid, "_region_map_size", 32)
|
||||
RenderingServer.material_set_param(process_rid, "_region_map", terrain.data.get_region_map())
|
||||
RenderingServer.material_set_param(process_rid, "_region_locations", terrain.data.get_region_locations())
|
||||
RenderingServer.material_set_param(process_rid, "_height_maps", terrain.data.get_height_maps_rid())
|
||||
RenderingServer.material_set_param(process_rid, "_control_maps", terrain.data.get_control_maps_rid())
|
||||
RenderingServer.material_set_param(process_rid, "_color_maps", terrain.data.get_color_maps_rid())
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_region_map", terrain.data.get_region_map()
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_region_locations", terrain.data.get_region_locations()
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_height_maps", terrain.data.get_height_maps_rid()
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_control_maps", terrain.data.get_control_maps_rid()
|
||||
)
|
||||
RenderingServer.material_set_param(
|
||||
process_rid, "_color_maps", terrain.data.get_color_maps_rid()
|
||||
)
|
||||
RenderingServer.material_set_param(process_rid, "instance_spacing", instance_spacing)
|
||||
RenderingServer.material_set_param(process_rid, "instance_rows", rows)
|
||||
RenderingServer.material_set_param(process_rid, "max_dist", min_draw_distance)
|
||||
|
||||
@@ -5,6 +5,7 @@ extends Button
|
||||
|
||||
signal dropped
|
||||
|
||||
|
||||
func _can_drop_data(p_position, p_data) -> bool:
|
||||
if typeof(p_data) == TYPE_DICTIONARY:
|
||||
if p_data.files.size() == 1:
|
||||
@@ -13,5 +14,6 @@ func _can_drop_data(p_position, p_data) -> bool:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _drop_data(p_position, p_data) -> void:
|
||||
dropped.emit(p_data.files[0])
|
||||
|
||||
@@ -41,7 +41,9 @@ func directory_setup_popup() -> void:
|
||||
plugin.ui.set_button_editor_icon(select_dir_btn, "Folder")
|
||||
|
||||
#Signals
|
||||
select_dir_btn.pressed.connect(_on_select_file_pressed.bind(EditorFileDialog.FILE_MODE_OPEN_DIR))
|
||||
select_dir_btn.pressed.connect(
|
||||
_on_select_file_pressed.bind(EditorFileDialog.FILE_MODE_OPEN_DIR)
|
||||
)
|
||||
dialog.confirmed.connect(_on_close_requested)
|
||||
dialog.canceled.connect(_on_close_requested)
|
||||
dialog.get_ok_button().pressed.connect(_on_ok_pressed)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Menu for Terrain3D
|
||||
extends HBoxContainer
|
||||
|
||||
|
||||
const DirectoryWizard: Script = preload("res://addons/terrain_3d/menu/directory_setup.gd")
|
||||
const Packer: Script = preload("res://addons/terrain_3d/menu/channel_packer.gd")
|
||||
const Baker: Script = preload("res://addons/terrain_3d/menu/baker.gd")
|
||||
|
||||
@@ -30,9 +30,9 @@ var search_button: Button
|
||||
|
||||
#DEPRECATED 4.5
|
||||
#class EdDock extends EditorDock:
|
||||
#func _update_layout(layout: int) -> void:
|
||||
#layout 1 vertical, 2 horizontal, 4 window
|
||||
#print("Terrain3DAssetDock: _update_layout called with: ", layout)
|
||||
#func _update_layout(layout: int) -> void:
|
||||
#layout 1 vertical, 2 horizontal, 4 window
|
||||
#print("Terrain3DAssetDock: _update_layout called with: ", layout)
|
||||
|
||||
var _dock: MarginContainer #DEPRECATED 4.5 - Use EdDock
|
||||
var _initialized: bool = false
|
||||
@@ -100,8 +100,12 @@ func initialize(p_plugin: EditorPlugin) -> void:
|
||||
size_slider.value_changed.connect(_on_slider_changed)
|
||||
plugin.ui.toolbar.tool_changed.connect(_on_tool_changed)
|
||||
|
||||
meshes_btn.add_theme_font_size_override("font_size", int(16. * EditorInterface.get_editor_scale()))
|
||||
textures_btn.add_theme_font_size_override("font_size", int(16. * EditorInterface.get_editor_scale()))
|
||||
meshes_btn.add_theme_font_size_override(
|
||||
"font_size", int(16. * EditorInterface.get_editor_scale())
|
||||
)
|
||||
textures_btn.add_theme_font_size_override(
|
||||
"font_size", int(16. * EditorInterface.get_editor_scale())
|
||||
)
|
||||
|
||||
search_box.text_changed.connect(_on_search_text_changed)
|
||||
search_button.pressed.connect(_on_search_button_pressed)
|
||||
@@ -109,12 +113,18 @@ func initialize(p_plugin: EditorPlugin) -> void:
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
add_child(confirm_dialog, true)
|
||||
confirm_dialog.hide()
|
||||
confirm_dialog.confirmed.connect(func(): _confirmed = true; \
|
||||
confirmation_closed.emit(); \
|
||||
confirmation_confirmed.emit() )
|
||||
confirm_dialog.canceled.connect(func(): _confirmed = false; \
|
||||
confirmation_closed.emit(); \
|
||||
confirmation_canceled.emit() )
|
||||
confirm_dialog.confirmed.connect(
|
||||
func():
|
||||
_confirmed = true
|
||||
confirmation_closed.emit()
|
||||
confirmation_confirmed.emit()
|
||||
)
|
||||
confirm_dialog.canceled.connect(
|
||||
func():
|
||||
_confirmed = false
|
||||
confirmation_closed.emit()
|
||||
confirmation_canceled.emit()
|
||||
)
|
||||
|
||||
# Setup styles
|
||||
set("theme_override_styles/panel", get_theme_stylebox("panel", "Panel"))
|
||||
@@ -298,7 +308,7 @@ func _on_tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor
|
||||
print("Terrain3DAssetDock: _on_tool_changed: ", p_tool, ", ", p_operation)
|
||||
if p_tool == Terrain3DEditor.INSTANCER:
|
||||
_on_meshes_pressed()
|
||||
elif p_tool in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
|
||||
elif p_tool in [Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS]:
|
||||
_on_textures_pressed()
|
||||
|
||||
|
||||
@@ -307,7 +317,9 @@ func _on_tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor
|
||||
|
||||
func update_assets() -> void:
|
||||
if plugin.debug:
|
||||
print("Terrain3DAssetDock: update_assets: ", plugin.terrain.assets if plugin.terrain else "")
|
||||
print(
|
||||
"Terrain3DAssetDock: update_assets: ", plugin.terrain.assets if plugin.terrain else ""
|
||||
)
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
@@ -327,7 +339,7 @@ func update_assets() -> void:
|
||||
func load_editor_settings() -> void:
|
||||
# Remove old editor settings
|
||||
const ES_DOCK: String = "terrain3d/dock/"
|
||||
for setting in [ "slot", "floating", "window_position", "window_size" ]:
|
||||
for setting in ["slot", "floating", "window_position", "window_size"]:
|
||||
plugin.erase_setting(ES_DOCK + setting)
|
||||
pinned_btn.button_pressed = plugin.get_setting(ES_DOCK_PINNED, true)
|
||||
size_slider.value = plugin.get_setting(ES_DOCK_TILE_SIZE, 90)
|
||||
@@ -351,7 +363,8 @@ func save_editor_settings() -> void:
|
||||
##############################################################
|
||||
|
||||
|
||||
class ListContainer extends Container:
|
||||
class ListContainer:
|
||||
extends Container
|
||||
var plugin: EditorPlugin
|
||||
var type := Terrain3DAssets.TYPE_TEXTURE
|
||||
var entries: Array[ListEntry]
|
||||
@@ -362,7 +375,6 @@ class ListContainer extends Container:
|
||||
var _clearing_resource: bool = false
|
||||
var search_text: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_v_size_flags(SIZE_EXPAND_FILL)
|
||||
set_h_size_flags(SIZE_EXPAND_FILL)
|
||||
@@ -371,14 +383,12 @@ class ListContainer extends Container:
|
||||
add_theme_constant_override("shadow_offset_x", 1)
|
||||
add_theme_constant_override("shadow_offset_y", 1)
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
for e in entries:
|
||||
e.get_parent().remove_child(e)
|
||||
e.queue_free()
|
||||
entries.clear()
|
||||
|
||||
|
||||
func update_asset_list() -> void:
|
||||
if plugin.debug:
|
||||
print("Terrain3DListContainer ", name, ": update_asset_list")
|
||||
@@ -407,7 +417,6 @@ class ListContainer extends Container:
|
||||
add_item()
|
||||
set_selected_id(selected_id)
|
||||
|
||||
|
||||
func add_item(p_resource: Resource = null) -> void:
|
||||
var entry: ListEntry = ListEntry.new()
|
||||
entry.focus_style = focus_style
|
||||
@@ -428,53 +437,74 @@ class ListContainer extends Container:
|
||||
if not p_resource.id_changed.is_connected(set_selected_after_swap):
|
||||
p_resource.id_changed.connect(set_selected_after_swap)
|
||||
|
||||
|
||||
func _on_resource_hovered(p_id: int):
|
||||
if type == Terrain3DAssets.TYPE_MESH:
|
||||
if plugin.terrain:
|
||||
plugin.terrain.assets.create_mesh_thumbnails(p_id)
|
||||
|
||||
|
||||
func set_selected_after_swap(p_type: Terrain3DAssets.AssetType, p_old_id: int, p_new_id: int) -> void:
|
||||
func set_selected_after_swap(
|
||||
p_type: Terrain3DAssets.AssetType, p_old_id: int, p_new_id: int
|
||||
) -> void:
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
set_selected_id(clamp(p_new_id, 0, entries.size() - 2))
|
||||
|
||||
|
||||
func clicked_id(p_id: int) -> void:
|
||||
# Select Tool if clicking an asset
|
||||
plugin.select_terrain()
|
||||
if type == Terrain3DAssets.TYPE_TEXTURE and \
|
||||
not plugin.editor.get_tool() in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
|
||||
if (
|
||||
type == Terrain3DAssets.TYPE_TEXTURE
|
||||
and not (
|
||||
plugin.editor.get_tool()
|
||||
in [Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS]
|
||||
)
|
||||
):
|
||||
var paint_btn: Button = plugin.ui.toolbar.get_node_or_null("PaintTexture")
|
||||
if paint_btn:
|
||||
paint_btn.set_pressed(true)
|
||||
plugin.ui._on_tool_changed(Terrain3DEditor.TEXTURE, Terrain3DEditor.REPLACE)
|
||||
elif type == Terrain3DAssets.TYPE_MESH and plugin.editor.get_tool() != Terrain3DEditor.INSTANCER:
|
||||
elif (
|
||||
type == Terrain3DAssets.TYPE_MESH
|
||||
and plugin.editor.get_tool() != Terrain3DEditor.INSTANCER
|
||||
):
|
||||
var instancer_btn: Button = plugin.ui.toolbar.get_node_or_null("InstanceMeshes")
|
||||
if instancer_btn:
|
||||
instancer_btn.set_pressed(true)
|
||||
plugin.ui._on_tool_changed(Terrain3DEditor.INSTANCER, Terrain3DEditor.ADD)
|
||||
set_selected_id(p_id)
|
||||
|
||||
|
||||
func set_selected_id(p_id: int) -> void:
|
||||
# "Add new" is the final entry only when search box is blank
|
||||
var max_id: int = max(0, entries.size() - (1 if search_text else 2))
|
||||
if plugin.debug:
|
||||
print("Terrain3DListContainer ", name, ": set_selected_id: ", selected_id, " to ", clamp(p_id, 0, max_id))
|
||||
print(
|
||||
"Terrain3DListContainer ",
|
||||
name,
|
||||
": set_selected_id: ",
|
||||
selected_id,
|
||||
" to ",
|
||||
clamp(p_id, 0, max_id)
|
||||
)
|
||||
selected_id = clamp(p_id, 0, max_id)
|
||||
for i in entries.size():
|
||||
var entry: ListEntry = entries[i]
|
||||
entry.set_selected(i == selected_id)
|
||||
plugin.ui._on_setting_changed()
|
||||
|
||||
|
||||
func get_selected_asset_id() -> int:
|
||||
# "Add new" is the final entry only when search box is blank
|
||||
var max_id: int = max(0, entries.size() - (1 if search_text else 2))
|
||||
var id: int = clamp(selected_id, 0, max_id)
|
||||
if plugin.debug:
|
||||
print("Terrain3DListContainer ", name, ": get_selected_asset_id: selected_id: ", selected_id, ", clamped: ", id, ", entries: ", entries.size())
|
||||
print(
|
||||
"Terrain3DListContainer ",
|
||||
name,
|
||||
": get_selected_asset_id: selected_id: ",
|
||||
selected_id,
|
||||
", clamped: ",
|
||||
id,
|
||||
", entries: ",
|
||||
entries.size()
|
||||
)
|
||||
if id >= entries.size():
|
||||
return 0
|
||||
var res: Resource = entries[id].resource
|
||||
@@ -485,18 +515,21 @@ class ListContainer extends Container:
|
||||
else:
|
||||
return (res as Terrain3DTextureAsset).id
|
||||
|
||||
|
||||
func _on_resource_inspected(p_resource: Resource) -> void:
|
||||
await get_tree().process_frame
|
||||
EditorInterface.edit_resource(p_resource)
|
||||
|
||||
|
||||
func _on_resource_changed(p_resource: Resource, p_id: int) -> void:
|
||||
if not p_resource and _clearing_resource:
|
||||
return
|
||||
if not p_resource:
|
||||
if plugin.debug:
|
||||
print("Terrain3DListContainer ", name, ": _on_resource_changed: removing asset ID: ", p_id)
|
||||
print(
|
||||
"Terrain3DListContainer ",
|
||||
name,
|
||||
": _on_resource_changed: removing asset ID: ",
|
||||
p_id
|
||||
)
|
||||
_clearing_resource = true
|
||||
var asset_dock: Control = get_parent().get_parent().get_parent()
|
||||
if type == Terrain3DAssets.TYPE_TEXTURE:
|
||||
@@ -525,17 +558,14 @@ class ListContainer extends Container:
|
||||
EditorInterface.inspect_object(null)
|
||||
_clearing_resource = false
|
||||
|
||||
|
||||
func set_entry_width(value: float) -> void:
|
||||
var min_width: float = 90.0 * max(1.0, EditorInterface.get_editor_scale())
|
||||
width = clamp(value, min_width, 512.0)
|
||||
redraw()
|
||||
|
||||
|
||||
func get_entry_width() -> float:
|
||||
return width
|
||||
|
||||
|
||||
func redraw() -> void:
|
||||
height = 0
|
||||
var id: int = 0
|
||||
@@ -543,22 +573,24 @@ class ListContainer extends Container:
|
||||
var columns: int = 3
|
||||
columns = clamp(size.x / width, 1, 100)
|
||||
var tile_size: Vector2 = Vector2(width, width) - Vector2(separation, separation)
|
||||
var name_font_size := int(clamp(tile_size.x/12., 12., 16.) * EditorInterface.get_editor_scale())
|
||||
var name_font_size := int(
|
||||
clamp(tile_size.x / 12., 12., 16.) * EditorInterface.get_editor_scale()
|
||||
)
|
||||
for c in get_children():
|
||||
if is_instance_valid(c):
|
||||
c.size = tile_size
|
||||
c.position = Vector2(id % columns, id / columns) * width + \
|
||||
Vector2(separation / columns, separation / columns)
|
||||
c.position = (
|
||||
Vector2(id % columns, id / columns) * width
|
||||
+ Vector2(separation / columns, separation / columns)
|
||||
)
|
||||
height = max(height, c.position.y + width)
|
||||
id += 1
|
||||
c.name_label.add_theme_font_size_override("font_size", name_font_size)
|
||||
|
||||
|
||||
# Needed to enable ScrollContainer scroll bar
|
||||
func _get_minimum_size() -> Vector2:
|
||||
return Vector2(0, height)
|
||||
|
||||
|
||||
func _notification(p_what) -> void:
|
||||
if p_what == NOTIFICATION_SORT_CHILDREN:
|
||||
redraw()
|
||||
@@ -569,9 +601,10 @@ class ListContainer extends Container:
|
||||
##############################################################
|
||||
|
||||
|
||||
class ListEntry extends MarginContainer:
|
||||
signal hovered()
|
||||
signal clicked()
|
||||
class ListEntry:
|
||||
extends MarginContainer
|
||||
signal hovered
|
||||
signal clicked
|
||||
signal changed(resource: Resource)
|
||||
signal inspected(resource: Resource)
|
||||
|
||||
@@ -597,7 +630,6 @@ class ListEntry extends MarginContainer:
|
||||
@onready var disabled_icon: Texture2D = get_theme_icon("GuiVisibilityHidden", "EditorIcons")
|
||||
@onready var add_icon: Texture2D = get_theme_icon("Add", "EditorIcons")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "ListEntry"
|
||||
custom_minimum_size = Vector2i(86., 86.)
|
||||
@@ -611,7 +643,6 @@ class ListEntry extends MarginContainer:
|
||||
focus_style.set_border_width_all(2)
|
||||
focus_style.set_border_color(Color(1, 1, 1, .67))
|
||||
|
||||
|
||||
func setup_buttons() -> void:
|
||||
destroy_buttons()
|
||||
|
||||
@@ -665,7 +696,6 @@ class ListEntry extends MarginContainer:
|
||||
button_clear.pressed.connect(_on_clear)
|
||||
button_row.add_child(button_clear, true)
|
||||
|
||||
|
||||
func destroy_buttons() -> void:
|
||||
if button_row:
|
||||
button_row.free()
|
||||
@@ -683,7 +713,6 @@ class ListEntry extends MarginContainer:
|
||||
button_clear.free()
|
||||
button_clear = null
|
||||
|
||||
|
||||
func get_resource_name() -> StringName:
|
||||
if resource:
|
||||
if resource is Terrain3DMeshAsset:
|
||||
@@ -692,7 +721,6 @@ class ListEntry extends MarginContainer:
|
||||
return (resource as Terrain3DTextureAsset).get_name()
|
||||
return ""
|
||||
|
||||
|
||||
func setup_label() -> void:
|
||||
name_label = Label.new()
|
||||
name_label.name = "MeshLabel"
|
||||
@@ -700,7 +728,9 @@ class ListEntry extends MarginContainer:
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
name_label.add_theme_font_size_override("font_size", int(14. * EditorInterface.get_editor_scale()))
|
||||
name_label.add_theme_font_size_override(
|
||||
"font_size", int(14. * EditorInterface.get_editor_scale())
|
||||
)
|
||||
name_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
name_label.add_theme_color_override("font_shadow_color", Color.BLACK)
|
||||
name_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||
@@ -710,7 +740,6 @@ class ListEntry extends MarginContainer:
|
||||
name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
add_child(name_label, true)
|
||||
|
||||
|
||||
func _notification(p_what) -> void:
|
||||
match p_what:
|
||||
NOTIFICATION_PREDELETE:
|
||||
@@ -758,7 +787,6 @@ class ListEntry extends MarginContainer:
|
||||
drop_data = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(p_event: InputEvent) -> void:
|
||||
if p_event is InputEventMouseButton:
|
||||
if p_event.is_pressed():
|
||||
@@ -780,7 +808,6 @@ class ListEntry extends MarginContainer:
|
||||
if resource:
|
||||
_on_clear()
|
||||
|
||||
|
||||
func _can_drop_data(p_at_position: Vector2, p_data: Variant) -> bool:
|
||||
drop_data = false
|
||||
if typeof(p_data) == TYPE_DICTIONARY:
|
||||
@@ -789,7 +816,6 @@ class ListEntry extends MarginContainer:
|
||||
drop_data = true
|
||||
return drop_data
|
||||
|
||||
|
||||
func _drop_data(p_at_position: Vector2, p_data: Variant) -> void:
|
||||
if typeof(p_data) == TYPE_DICTIONARY:
|
||||
var res: Resource = load(p_data.files[0])
|
||||
@@ -816,7 +842,6 @@ class ListEntry extends MarginContainer:
|
||||
emit_signal("clicked")
|
||||
emit_signal("inspected", resource)
|
||||
|
||||
|
||||
func set_edited_resource(p_res: Resource, p_no_signal: bool = true) -> void:
|
||||
resource = p_res
|
||||
if resource:
|
||||
@@ -836,12 +861,10 @@ class ListEntry extends MarginContainer:
|
||||
if not p_no_signal:
|
||||
emit_signal("changed", resource)
|
||||
|
||||
|
||||
func _on_resource_changed(_value: int = 0) -> void:
|
||||
queue_redraw()
|
||||
emit_signal("changed", resource)
|
||||
|
||||
|
||||
func set_selected(value: bool) -> void:
|
||||
if not is_inside_tree():
|
||||
#push_error("not in tree")
|
||||
@@ -851,26 +874,24 @@ class ListEntry extends MarginContainer:
|
||||
# Handle scrolling to show the selected item
|
||||
await get_tree().process_frame
|
||||
if is_inside_tree():
|
||||
get_parent().get_parent().get_v_scroll_bar().ratio = position.y / get_parent().size.y
|
||||
get_parent().get_parent().get_v_scroll_bar().ratio = (
|
||||
position.y / get_parent().size.y
|
||||
)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _on_clear() -> void:
|
||||
if resource:
|
||||
name_label.hide()
|
||||
set_edited_resource(null, false)
|
||||
|
||||
|
||||
func _on_edit() -> void:
|
||||
emit_signal("clicked")
|
||||
emit_signal("inspected", resource)
|
||||
|
||||
|
||||
func _on_enable() -> void:
|
||||
if resource is Terrain3DMeshAsset:
|
||||
resource.set_enabled(!resource.is_enabled())
|
||||
|
||||
|
||||
func _format_number(num: int) -> String:
|
||||
var is_negative: bool = num < 0
|
||||
var str_num: String = str(abs(num))
|
||||
|
||||
@@ -98,7 +98,7 @@ func initialize(p_plugin: EditorPlugin) -> void:
|
||||
placement_opt.item_selected.connect(set_slot)
|
||||
floating_btn.pressed.connect(make_dock_float)
|
||||
pinned_btn.toggled.connect(_on_pin_changed)
|
||||
pinned_btn.visible = ( window != null )
|
||||
pinned_btn.visible = (window != null)
|
||||
size_slider.value_changed.connect(_on_slider_changed)
|
||||
plugin.ui.toolbar.tool_changed.connect(_on_tool_changed)
|
||||
|
||||
@@ -127,12 +127,18 @@ func _ready() -> void:
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
add_child(confirm_dialog)
|
||||
confirm_dialog.hide()
|
||||
confirm_dialog.confirmed.connect(func(): _confirmed = true; \
|
||||
emit_signal("confirmation_closed"); \
|
||||
emit_signal("confirmation_confirmed") )
|
||||
confirm_dialog.canceled.connect(func(): _confirmed = false; \
|
||||
emit_signal("confirmation_closed"); \
|
||||
emit_signal("confirmation_canceled") )
|
||||
confirm_dialog.confirmed.connect(
|
||||
func():
|
||||
_confirmed = true
|
||||
emit_signal("confirmation_closed")
|
||||
emit_signal("confirmation_confirmed")
|
||||
)
|
||||
confirm_dialog.canceled.connect(
|
||||
func():
|
||||
_confirmed = false
|
||||
emit_signal("confirmation_closed")
|
||||
emit_signal("confirmation_canceled")
|
||||
)
|
||||
|
||||
|
||||
func get_current_list() -> ListContainer:
|
||||
@@ -141,8 +147,9 @@ func get_current_list() -> ListContainer:
|
||||
|
||||
## Dock placement
|
||||
|
||||
|
||||
func set_slot(p_slot: int) -> void:
|
||||
p_slot = clamp(p_slot, 0, POS_MAX-1)
|
||||
p_slot = clamp(p_slot, 0, POS_MAX - 1)
|
||||
|
||||
if slot != p_slot:
|
||||
slot = p_slot
|
||||
@@ -238,8 +245,10 @@ func update_layout() -> void:
|
||||
func update_thumbnails() -> void:
|
||||
if not is_instance_valid(plugin.terrain):
|
||||
return
|
||||
if current_list.type == Terrain3DAssets.TYPE_MESH and \
|
||||
Time.get_ticks_msec() - _last_thumb_update_time > MAX_UPDATE_TIME:
|
||||
if (
|
||||
current_list.type == Terrain3DAssets.TYPE_MESH
|
||||
and Time.get_ticks_msec() - _last_thumb_update_time > MAX_UPDATE_TIME
|
||||
):
|
||||
plugin.terrain.assets.create_mesh_thumbnails()
|
||||
_last_thumb_update_time = Time.get_ticks_msec()
|
||||
for mesh_asset in mesh_list.entries:
|
||||
@@ -293,7 +302,7 @@ func _on_meshes_pressed() -> void:
|
||||
func _on_tool_changed(p_tool: Terrain3DEditor.Tool, p_operation: Terrain3DEditor.Operation) -> void:
|
||||
if p_tool == Terrain3DEditor.INSTANCER:
|
||||
_on_meshes_pressed()
|
||||
elif p_tool in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
|
||||
elif p_tool in [Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS]:
|
||||
_on_textures_pressed()
|
||||
|
||||
|
||||
@@ -366,13 +375,18 @@ func clamp_window_position() -> void:
|
||||
bounds = DisplayServer.screen_get_position(window.current_screen)
|
||||
bounds += DisplayServer.screen_get_size(window.current_screen)
|
||||
var margin: int = 40
|
||||
window.position.x = clamp(window.position.x, -window.size.x + 2*margin, bounds.x - margin)
|
||||
window.position.x = clamp(window.position.x, -window.size.x + 2 * margin, bounds.x - margin)
|
||||
window.position.y = clamp(window.position.y, 25, bounds.y - margin)
|
||||
|
||||
|
||||
func _on_window_input(event: InputEvent) -> void:
|
||||
# Capture CTRL+S when doc focused to save scene
|
||||
if event is InputEventKey and event.keycode == KEY_S and event.pressed and event.is_command_or_control_pressed():
|
||||
if (
|
||||
event is InputEventKey
|
||||
and event.keycode == KEY_S
|
||||
and event.pressed
|
||||
and event.is_command_or_control_pressed()
|
||||
):
|
||||
save_editor_settings()
|
||||
EditorInterface.save_scene()
|
||||
|
||||
@@ -385,7 +399,10 @@ func _on_godot_window_entered() -> void:
|
||||
func _on_godot_focus_entered() -> void:
|
||||
# If asset dock is windowed, and Godot was minimized, and now is not, restore asset dock window
|
||||
if is_instance_valid(window):
|
||||
if _godot_last_state == Window.MODE_MINIMIZED and plugin.godot_editor_window.mode != Window.MODE_MINIMIZED:
|
||||
if (
|
||||
_godot_last_state == Window.MODE_MINIMIZED
|
||||
and plugin.godot_editor_window.mode != Window.MODE_MINIMIZED
|
||||
):
|
||||
window.show()
|
||||
_godot_last_state = plugin.godot_editor_window.mode
|
||||
plugin.godot_editor_window.grab_focus()
|
||||
@@ -399,6 +416,7 @@ func _on_godot_focus_exited() -> void:
|
||||
|
||||
## Manage Editor Settings
|
||||
|
||||
|
||||
func load_editor_settings() -> void:
|
||||
floating_btn.button_pressed = plugin.get_setting(ES_DOCK_FLOATING, false)
|
||||
pinned_btn.button_pressed = plugin.get_setting(ES_DOCK_PINNED, true)
|
||||
@@ -432,7 +450,8 @@ func save_editor_settings() -> void:
|
||||
##############################################################
|
||||
|
||||
|
||||
class ListContainer extends Container:
|
||||
class ListContainer:
|
||||
extends Container
|
||||
var plugin: EditorPlugin
|
||||
var type := Terrain3DAssets.TYPE_TEXTURE
|
||||
var entries: Array[ListEntry]
|
||||
@@ -441,19 +460,16 @@ class ListContainer extends Container:
|
||||
var width: float = 83
|
||||
var focus_style: StyleBox
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_v_size_flags(SIZE_EXPAND_FILL)
|
||||
set_h_size_flags(SIZE_EXPAND_FILL)
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
for e in entries:
|
||||
e.get_parent().remove_child(e)
|
||||
e.queue_free()
|
||||
entries.clear()
|
||||
|
||||
|
||||
func update_asset_list() -> void:
|
||||
clear()
|
||||
|
||||
@@ -461,7 +477,10 @@ class ListContainer extends Container:
|
||||
var t: Terrain3D
|
||||
if plugin.is_terrain_valid():
|
||||
t = plugin.terrain
|
||||
elif is_instance_valid(plugin._last_terrain) and plugin.is_terrain_valid(plugin._last_terrain):
|
||||
elif (
|
||||
is_instance_valid(plugin._last_terrain)
|
||||
and plugin.is_terrain_valid(plugin._last_terrain)
|
||||
):
|
||||
t = plugin._last_terrain
|
||||
else:
|
||||
return
|
||||
@@ -486,7 +505,6 @@ class ListContainer extends Container:
|
||||
if selected_id >= mesh_count or selected_id < 0:
|
||||
set_selected_id(0)
|
||||
|
||||
|
||||
func add_item(p_resource: Resource = null, p_assets: Terrain3DAssets = null) -> void:
|
||||
var entry: ListEntry = ListEntry.new()
|
||||
entry.focus_style = focus_style
|
||||
@@ -507,17 +525,16 @@ class ListContainer extends Container:
|
||||
if not p_resource.id_changed.is_connected(set_selected_after_swap):
|
||||
p_resource.id_changed.connect(set_selected_after_swap)
|
||||
|
||||
|
||||
func _on_resource_hovered(p_id: int):
|
||||
if type == Terrain3DAssets.TYPE_MESH:
|
||||
if plugin.terrain:
|
||||
plugin.terrain.assets.create_mesh_thumbnails(p_id)
|
||||
|
||||
|
||||
func set_selected_after_swap(p_type: Terrain3DAssets.AssetType, p_old_id: int, p_new_id: int) -> void:
|
||||
func set_selected_after_swap(
|
||||
p_type: Terrain3DAssets.AssetType, p_old_id: int, p_new_id: int
|
||||
) -> void:
|
||||
set_selected_id(clamp(p_new_id, 0, entries.size() - 2))
|
||||
|
||||
|
||||
func set_selected_id(p_id: int) -> void:
|
||||
selected_id = p_id
|
||||
|
||||
@@ -528,14 +545,22 @@ class ListContainer extends Container:
|
||||
plugin.select_terrain()
|
||||
|
||||
# Select Paint tool if clicking a texture
|
||||
if type == Terrain3DAssets.TYPE_TEXTURE and \
|
||||
not plugin.editor.get_tool() in [ Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS ]:
|
||||
if (
|
||||
type == Terrain3DAssets.TYPE_TEXTURE
|
||||
and not (
|
||||
plugin.editor.get_tool()
|
||||
in [Terrain3DEditor.TEXTURE, Terrain3DEditor.COLOR, Terrain3DEditor.ROUGHNESS]
|
||||
)
|
||||
):
|
||||
var paint_btn: Button = plugin.ui.toolbar.get_node_or_null("PaintTexture")
|
||||
if paint_btn:
|
||||
paint_btn.set_pressed(true)
|
||||
plugin.ui._on_tool_changed(Terrain3DEditor.TEXTURE, Terrain3DEditor.REPLACE)
|
||||
|
||||
elif type == Terrain3DAssets.TYPE_MESH and plugin.editor.get_tool() != Terrain3DEditor.INSTANCER:
|
||||
elif (
|
||||
type == Terrain3DAssets.TYPE_MESH
|
||||
and plugin.editor.get_tool() != Terrain3DEditor.INSTANCER
|
||||
):
|
||||
var instancer_btn: Button = plugin.ui.toolbar.get_node_or_null("InstanceMeshes")
|
||||
if instancer_btn:
|
||||
instancer_btn.set_pressed(true)
|
||||
@@ -544,12 +569,10 @@ class ListContainer extends Container:
|
||||
# Update editor with selected brush
|
||||
plugin.ui._on_setting_changed()
|
||||
|
||||
|
||||
func _on_resource_inspected(p_resource: Resource) -> void:
|
||||
await get_tree().create_timer(.01).timeout
|
||||
EditorInterface.edit_resource(p_resource)
|
||||
|
||||
|
||||
func _on_resource_changed(p_resource: Resource, p_id: int) -> void:
|
||||
if not p_resource:
|
||||
var asset_dock: Control = get_parent().get_parent().get_parent()
|
||||
@@ -582,28 +605,23 @@ class ListContainer extends Container:
|
||||
# If null resource, remove last
|
||||
if not p_resource:
|
||||
var last_offset: int = 2
|
||||
if p_id == entries.size()-2:
|
||||
if p_id == entries.size() - 2:
|
||||
last_offset = 3
|
||||
set_selected_id(clamp(selected_id, 0, entries.size() - last_offset))
|
||||
|
||||
|
||||
func get_selected_id() -> int:
|
||||
return selected_id
|
||||
|
||||
|
||||
func get_selected_asset_id() -> int:
|
||||
return selected_id
|
||||
|
||||
|
||||
func set_entry_width(value: float) -> void:
|
||||
width = clamp(value, 66, 230)
|
||||
redraw()
|
||||
|
||||
|
||||
func get_entry_width() -> float:
|
||||
return width
|
||||
|
||||
|
||||
func redraw() -> void:
|
||||
height = 0
|
||||
var id: int = 0
|
||||
@@ -614,17 +632,17 @@ class ListContainer extends Container:
|
||||
for c in get_children():
|
||||
if is_instance_valid(c):
|
||||
c.size = Vector2(width, width) - Vector2(separation, separation)
|
||||
c.position = Vector2(id % columns, id / columns) * width + \
|
||||
Vector2(separation / columns, separation / columns)
|
||||
c.position = (
|
||||
Vector2(id % columns, id / columns) * width
|
||||
+ Vector2(separation / columns, separation / columns)
|
||||
)
|
||||
height = max(height, c.position.y + width)
|
||||
id += 1
|
||||
|
||||
|
||||
# Needed to enable ScrollContainer scroll bar
|
||||
func _get_minimum_size() -> Vector2:
|
||||
return Vector2(0, height)
|
||||
|
||||
|
||||
func _notification(p_what) -> void:
|
||||
if p_what == NOTIFICATION_SORT_CHILDREN:
|
||||
redraw()
|
||||
@@ -635,9 +653,10 @@ class ListContainer extends Container:
|
||||
##############################################################
|
||||
|
||||
|
||||
class ListEntry extends VBoxContainer:
|
||||
signal hovered()
|
||||
signal selected()
|
||||
class ListEntry:
|
||||
extends VBoxContainer
|
||||
signal hovered
|
||||
signal selected
|
||||
signal changed(resource: Resource)
|
||||
signal inspected(resource: Resource)
|
||||
|
||||
@@ -664,14 +683,12 @@ class ListEntry extends VBoxContainer:
|
||||
@onready var background: StyleBox = get_theme_stylebox("pressed", "Button")
|
||||
@onready var focus_style: StyleBox = get_theme_stylebox("focus", "Button").duplicate()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
setup_buttons()
|
||||
setup_label()
|
||||
focus_style.set_border_width_all(2)
|
||||
focus_style.set_border_color(Color(1, 1, 1, .67))
|
||||
|
||||
|
||||
func setup_buttons() -> void:
|
||||
var icon_size: Vector2 = Vector2(12, 12)
|
||||
var margin_container := MarginContainer.new()
|
||||
@@ -717,7 +734,6 @@ class ListEntry extends VBoxContainer:
|
||||
button_clear.pressed.connect(clear)
|
||||
button_row.add_child(button_clear)
|
||||
|
||||
|
||||
func setup_label() -> void:
|
||||
name_label = Label.new()
|
||||
add_child(name_label, true)
|
||||
@@ -737,7 +753,6 @@ class ListEntry extends VBoxContainer:
|
||||
else:
|
||||
name_label.text = "Add Mesh"
|
||||
|
||||
|
||||
func _notification(p_what) -> void:
|
||||
match p_what:
|
||||
NOTIFICATION_DRAW:
|
||||
@@ -766,7 +781,7 @@ class ListEntry extends VBoxContainer:
|
||||
else:
|
||||
draw_rect(rect, Color(.15, .15, .15, 1.))
|
||||
button_enabled.set_pressed_no_signal(!resource.is_enabled())
|
||||
name_label.add_theme_font_size_override("font_size", 4 + rect.size.x/10)
|
||||
name_label.add_theme_font_size_override("font_size", 4 + rect.size.x / 10)
|
||||
if drop_data:
|
||||
draw_style_box(focus_style, rect)
|
||||
if is_hovered:
|
||||
@@ -784,7 +799,6 @@ class ListEntry extends VBoxContainer:
|
||||
drop_data = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(p_event: InputEvent) -> void:
|
||||
if p_event is InputEventMouseButton:
|
||||
if p_event.is_pressed():
|
||||
@@ -806,7 +820,6 @@ class ListEntry extends VBoxContainer:
|
||||
if resource:
|
||||
clear()
|
||||
|
||||
|
||||
func _can_drop_data(p_at_position: Vector2, p_data: Variant) -> bool:
|
||||
drop_data = false
|
||||
if typeof(p_data) == TYPE_DICTIONARY:
|
||||
@@ -815,7 +828,6 @@ class ListEntry extends VBoxContainer:
|
||||
drop_data = true
|
||||
return drop_data
|
||||
|
||||
|
||||
func _drop_data(p_at_position: Vector2, p_data: Variant) -> void:
|
||||
if typeof(p_data) == TYPE_DICTIONARY:
|
||||
var res: Resource = load(p_data.files[0])
|
||||
@@ -844,8 +856,6 @@ class ListEntry extends VBoxContainer:
|
||||
emit_signal("selected")
|
||||
emit_signal("inspected", resource)
|
||||
|
||||
|
||||
|
||||
func set_edited_resource(p_res: Resource, p_no_signal: bool = true) -> void:
|
||||
resource = p_res
|
||||
if resource:
|
||||
@@ -861,27 +871,22 @@ class ListEntry extends VBoxContainer:
|
||||
if !p_no_signal:
|
||||
emit_signal("changed", resource)
|
||||
|
||||
|
||||
func _on_resource_changed() -> void:
|
||||
queue_redraw()
|
||||
emit_signal("changed", resource)
|
||||
|
||||
|
||||
func set_selected(value: bool) -> void:
|
||||
is_selected = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
if resource:
|
||||
set_edited_resource(null, false)
|
||||
|
||||
|
||||
func edit() -> void:
|
||||
emit_signal("selected")
|
||||
emit_signal("inspected", resource)
|
||||
|
||||
|
||||
func enable() -> void:
|
||||
if resource is Terrain3DMeshAsset:
|
||||
resource.set_enabled(!resource.is_enabled())
|
||||
|
||||
@@ -99,7 +99,7 @@ func _get_handle() -> int:
|
||||
func _gui_input(p_event: InputEvent) -> void:
|
||||
if p_event is InputEventMouseButton:
|
||||
var button: int = p_event.get_button_index()
|
||||
if button in [ MOUSE_BUTTON_LEFT, MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN ]:
|
||||
if button in [MOUSE_BUTTON_LEFT, MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
|
||||
if p_event.is_pressed():
|
||||
var mid_point = (range.x + range.y) / 2.0
|
||||
var xpos: float = p_event.get_position().x * 2.0
|
||||
@@ -125,8 +125,10 @@ func _gui_input(p_event: InputEvent) -> void:
|
||||
func set_slider(p_xpos: float, p_relative: bool = false) -> void:
|
||||
if grabbed_handle == 0:
|
||||
return
|
||||
var xpos_step: float = clamp(snappedf((p_xpos / size.x) * max_value, step), min_value, max_value)
|
||||
if(grabbed_handle < 0):
|
||||
var xpos_step: float = clamp(
|
||||
snappedf((p_xpos / size.x) * max_value, step), min_value, max_value
|
||||
)
|
||||
if grabbed_handle < 0:
|
||||
if p_relative:
|
||||
range.x += p_xpos
|
||||
else:
|
||||
@@ -156,8 +158,8 @@ func _notification(p_what: int) -> void:
|
||||
|
||||
# Draw handles, slightly in so they don't get on the outside edges
|
||||
var handle_pos: Vector2
|
||||
handle_pos.x = clamp(startx - handle.get_size().x/2, -10, size.x)
|
||||
handle_pos.y = clamp(endx - handle.get_size().x/2, 0, size.x - 10)
|
||||
handle_pos.x = clamp(startx - handle.get_size().x / 2, -10, size.x)
|
||||
handle_pos.y = clamp(endx - handle.get_size().x / 2, 0, size.x - 10)
|
||||
draw_texture(handle, Vector2(handle_pos.x, -mid_y - 10 * (display_scale - 1.)))
|
||||
draw_texture(handle, Vector2(handle_pos.y, -mid_y - 10 * (display_scale - 1.)))
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
|
||||
# Includes
|
||||
const UI: Script = preload("res://addons/terrain_3d/src/ui.gd")
|
||||
const RegionGizmo: Script = preload("res://addons/terrain_3d/src/region_gizmo.gd")
|
||||
@@ -201,11 +200,15 @@ func _forward_3d_gui_input(p_viewport_camera: Camera3D, p_event: InputEvent) ->
|
||||
|
||||
## Handle mouse movement
|
||||
if p_event is InputEventMouseMotion:
|
||||
|
||||
if _input_mode != -1: # Not cam rotation
|
||||
## Update region highlight
|
||||
var region_position: Vector2 = ( Vector2(mouse_global_position.x, mouse_global_position.z) \
|
||||
/ (terrain.get_region_size() * terrain.get_vertex_spacing()) ).floor()
|
||||
var region_position: Vector2 = (
|
||||
(
|
||||
Vector2(mouse_global_position.x, mouse_global_position.z)
|
||||
/ (terrain.get_region_size() * terrain.get_vertex_spacing())
|
||||
)
|
||||
. floor()
|
||||
)
|
||||
if current_region_position != region_position:
|
||||
current_region_position = region_position
|
||||
update_region_grid()
|
||||
@@ -237,13 +240,17 @@ func _forward_3d_gui_input(p_viewport_camera: Camera3D, p_event: InputEvent) ->
|
||||
# Skip regions that already exist or don't
|
||||
var has_region: bool = terrain.data.has_regionp(mouse_global_position)
|
||||
var op: int = editor.get_operation()
|
||||
if ( has_region and op == Terrain3DEditor.ADD) or \
|
||||
( not has_region and op == Terrain3DEditor.SUBTRACT ):
|
||||
if (
|
||||
(has_region and op == Terrain3DEditor.ADD)
|
||||
or (not has_region and op == Terrain3DEditor.SUBTRACT)
|
||||
):
|
||||
return AFTER_GUI_INPUT_STOP
|
||||
|
||||
# If an automatic operation is ready to go (e.g. gradient)
|
||||
if ui.operation_builder and ui.operation_builder.is_ready():
|
||||
ui.operation_builder.apply_operation(editor, mouse_global_position, p_viewport_camera.rotation.y)
|
||||
ui.operation_builder.apply_operation(
|
||||
editor, mouse_global_position, p_viewport_camera.rotation.y
|
||||
)
|
||||
return AFTER_GUI_INPUT_STOP
|
||||
|
||||
# Mouse clicked, start editing
|
||||
@@ -261,29 +268,54 @@ func _forward_3d_gui_input(p_viewport_camera: Camera3D, p_event: InputEvent) ->
|
||||
|
||||
func _read_input(p_event: InputEvent = null) -> AfterGUIInput:
|
||||
## Determine if user is moving camera or applying
|
||||
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or \
|
||||
p_event is InputEventMouseButton and p_event.is_released() and \
|
||||
p_event.get_button_index() == MOUSE_BUTTON_LEFT:
|
||||
if (
|
||||
Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
|
||||
or (
|
||||
p_event is InputEventMouseButton
|
||||
and p_event.is_released()
|
||||
and p_event.get_button_index() == MOUSE_BUTTON_LEFT
|
||||
)
|
||||
):
|
||||
_input_mode = 1
|
||||
else:
|
||||
_input_mode = 0
|
||||
|
||||
match get_setting("editors/3d/navigation/navigation_scheme", 0):
|
||||
2, 1: # Modo, Maya
|
||||
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or \
|
||||
( Input.is_key_pressed(KEY_ALT) and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) ):
|
||||
if (
|
||||
Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
||||
or (
|
||||
Input.is_key_pressed(KEY_ALT)
|
||||
and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
|
||||
)
|
||||
):
|
||||
_input_mode = -1
|
||||
if p_event is InputEventMouseButton and p_event.is_released() and \
|
||||
( p_event.get_button_index() == MOUSE_BUTTON_RIGHT or \
|
||||
( Input.is_key_pressed(KEY_ALT) and p_event.get_button_index() == MOUSE_BUTTON_LEFT )):
|
||||
if (
|
||||
p_event is InputEventMouseButton
|
||||
and p_event.is_released()
|
||||
and (
|
||||
p_event.get_button_index() == MOUSE_BUTTON_RIGHT
|
||||
or (
|
||||
Input.is_key_pressed(KEY_ALT)
|
||||
and p_event.get_button_index() == MOUSE_BUTTON_LEFT
|
||||
)
|
||||
)
|
||||
):
|
||||
rmb_release_time = Time.get_ticks_msec()
|
||||
0, _: # Godot
|
||||
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or \
|
||||
Input.is_mouse_button_pressed(MOUSE_BUTTON_MIDDLE):
|
||||
if (
|
||||
Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
||||
or Input.is_mouse_button_pressed(MOUSE_BUTTON_MIDDLE)
|
||||
):
|
||||
_input_mode = -1
|
||||
if p_event is InputEventMouseButton and p_event.is_released() and \
|
||||
( p_event.get_button_index() == MOUSE_BUTTON_RIGHT or \
|
||||
p_event.get_button_index() == MOUSE_BUTTON_MIDDLE ):
|
||||
if (
|
||||
p_event is InputEventMouseButton
|
||||
and p_event.is_released()
|
||||
and (
|
||||
p_event.get_button_index() == MOUSE_BUTTON_RIGHT
|
||||
or p_event.get_button_index() == MOUSE_BUTTON_MIDDLE
|
||||
)
|
||||
):
|
||||
rmb_release_time = Time.get_ticks_msec()
|
||||
if _input_mode < 0:
|
||||
# Camera is moving, skip input
|
||||
@@ -301,19 +333,25 @@ func _read_input(p_event: InputEvent = null) -> AfterGUIInput:
|
||||
# Keybind enum: Alt,Space,Meta,Capslock
|
||||
var alt_key: int
|
||||
match get_setting("terrain3d/config/alt_key_bind", 0):
|
||||
3: alt_key = KEY_CAPSLOCK
|
||||
2: alt_key = KEY_META
|
||||
1: alt_key = KEY_SPACE
|
||||
0, _: alt_key = KEY_ALT
|
||||
3:
|
||||
alt_key = KEY_CAPSLOCK
|
||||
2:
|
||||
alt_key = KEY_META
|
||||
1:
|
||||
alt_key = KEY_SPACE
|
||||
0, _:
|
||||
alt_key = KEY_ALT
|
||||
modifier_alt = Input.is_key_pressed(alt_key)
|
||||
var current_mods: int = int(modifier_shift) | int(modifier_ctrl) << 1 | int(modifier_alt) << 2
|
||||
|
||||
## Process Hotkeys
|
||||
if p_event is InputEventKey and \
|
||||
current_mods == 0 and \
|
||||
p_event.is_pressed() and \
|
||||
not p_event.is_echo() and \
|
||||
consume_hotkey(p_event.keycode):
|
||||
if (
|
||||
p_event is InputEventKey
|
||||
and current_mods == 0
|
||||
and p_event.is_pressed()
|
||||
and not p_event.is_echo()
|
||||
and consume_hotkey(p_event.keycode)
|
||||
):
|
||||
# Hotkey found, consume event, and stop input processing
|
||||
EditorInterface.get_editor_viewport_3d().set_input_as_handled()
|
||||
return AFTER_GUI_INPUT_STOP
|
||||
@@ -426,8 +464,13 @@ func is_terrain_valid(p_terrain: Terrain3D = null) -> bool:
|
||||
func is_selected() -> bool:
|
||||
var selected: Array[Node] = EditorInterface.get_selection().get_selected_nodes()
|
||||
for node in selected:
|
||||
if ( is_instance_valid(_last_terrain) and node.get_instance_id() == _last_terrain.get_instance_id() ) or \
|
||||
node is Terrain3D:
|
||||
if (
|
||||
(
|
||||
is_instance_valid(_last_terrain)
|
||||
and node.get_instance_id() == _last_terrain.get_instance_id()
|
||||
)
|
||||
or node is Terrain3D
|
||||
):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Gradient Operation Builder for Terrain3D
|
||||
extends "res://addons/terrain_3d/src/operation_builder.gd"
|
||||
|
||||
|
||||
const MultiPicker: Script = preload("res://addons/terrain_3d/src/multi_picker.gd")
|
||||
|
||||
|
||||
@@ -31,7 +30,9 @@ func is_ready() -> bool:
|
||||
return _get_point_picker().all_points_selected() and not _is_drawable()
|
||||
|
||||
|
||||
func apply_operation(p_editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float) -> void:
|
||||
func apply_operation(
|
||||
p_editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float
|
||||
) -> void:
|
||||
var points: PackedVector3Array = _get_point_picker().get_points()
|
||||
assert(points.size() == 2)
|
||||
assert(not _is_drawable())
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
# Multipicker for Terrain3D
|
||||
extends HBoxContainer
|
||||
|
||||
|
||||
signal pressed
|
||||
signal value_changed
|
||||
|
||||
|
||||
const ICON_PICKER_CHECKED: String = "res://addons/terrain_3d/icons/picker_checked.svg"
|
||||
const MAX_POINTS: int = 2
|
||||
|
||||
|
||||
var icon_picker: Texture2D
|
||||
var icon_picker_checked: Texture2D
|
||||
var points: PackedVector3Array
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
# Operation Builder for Terrain3D
|
||||
extends RefCounted
|
||||
|
||||
|
||||
const ToolSettings: Script = preload("res://addons/terrain_3d/src/tool_settings.gd")
|
||||
|
||||
|
||||
var tool_settings: ToolSettings
|
||||
|
||||
|
||||
@@ -21,5 +19,7 @@ func is_ready() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func apply_operation(editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float) -> void:
|
||||
func apply_operation(
|
||||
editor: Terrain3DEditor, p_global_position: Vector3, p_camera_direction: float
|
||||
) -> void:
|
||||
pass
|
||||
|
||||
@@ -34,9 +34,17 @@ func _redraw() -> void:
|
||||
|
||||
if show_rect:
|
||||
var modulate: Color = main_color if !use_secondary_color else secondary_color
|
||||
if abs(region_position.x) > Terrain3DData.REGION_MAP_SIZE*.5 or abs(region_position.y) > Terrain3DData.REGION_MAP_SIZE*.5:
|
||||
if (
|
||||
abs(region_position.x) > Terrain3DData.REGION_MAP_SIZE * .5
|
||||
or abs(region_position.y) > Terrain3DData.REGION_MAP_SIZE * .5
|
||||
):
|
||||
modulate = Color.GRAY
|
||||
draw_rect(Vector2(region_size,region_size)*.5 + rect_position, region_size, selection_material, modulate)
|
||||
draw_rect(
|
||||
Vector2(region_size, region_size) * .5 + rect_position,
|
||||
region_size,
|
||||
selection_material,
|
||||
modulate
|
||||
)
|
||||
|
||||
for pos in grid:
|
||||
var grid_tile_position = Vector2(pos) * region_size
|
||||
@@ -44,12 +52,19 @@ func _redraw() -> void:
|
||||
# Skip this one, otherwise focused region borders are not always visible due to draw order
|
||||
continue
|
||||
|
||||
draw_rect(Vector2(region_size,region_size)*.5 + grid_tile_position, region_size, material, grid_color)
|
||||
draw_rect(
|
||||
Vector2(region_size, region_size) * .5 + grid_tile_position,
|
||||
region_size,
|
||||
material,
|
||||
grid_color
|
||||
)
|
||||
|
||||
draw_rect(Vector2.ZERO, region_size * Terrain3DData.REGION_MAP_SIZE, material, border_color)
|
||||
|
||||
|
||||
func draw_rect(p_pos: Vector2, p_size: float, p_material: StandardMaterial3D, p_modulate: Color) -> void:
|
||||
func draw_rect(
|
||||
p_pos: Vector2, p_size: float, p_material: StandardMaterial3D, p_modulate: Color
|
||||
) -> void:
|
||||
var lines: PackedVector3Array = [
|
||||
Vector3(-1, 0, -1),
|
||||
Vector3(-1, 0, 1),
|
||||
@@ -65,4 +80,3 @@ func draw_rect(p_pos: Vector2, p_size: float, p_material: StandardMaterial3D, p_
|
||||
lines[i] = ((lines[i] / 2.0) * p_size) + Vector3(p_pos.x, 0, p_pos.y)
|
||||
|
||||
add_lines(lines, p_material, false, p_modulate)
|
||||
|
||||
|
||||
@@ -62,103 +62,394 @@ func _ready() -> void:
|
||||
|
||||
add_brushes(main_list)
|
||||
|
||||
add_setting({ "name":"instructions", "label":"Click the terrain to add a region. CTRL+Click to remove. Or select another tool on the left.",
|
||||
"type":SettingType.LABEL, "list":main_list, "flags":NO_LABEL|NO_SAVE })
|
||||
add_setting(
|
||||
{
|
||||
"name": "instructions",
|
||||
"label":
|
||||
"Click the terrain to add a region. CTRL+Click to remove. Or select another tool on the left.",
|
||||
"type": SettingType.LABEL,
|
||||
"list": main_list,
|
||||
"flags": NO_LABEL | NO_SAVE
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"size", "type":SettingType.SLIDER, "list":main_list, "default":20, "unit":"m",
|
||||
"range":Vector3(0.1, 200, 1), "flags":ALLOW_LARGER|ADD_SPACER })
|
||||
add_setting(
|
||||
{
|
||||
"name": "size",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 20,
|
||||
"unit": "m",
|
||||
"range": Vector3(0.1, 200, 1),
|
||||
"flags": ALLOW_LARGER | ADD_SPACER
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"strength", "type":SettingType.SLIDER, "list":main_list, "default":33,
|
||||
"unit":"%", "range":Vector3(1, 100, 1), "flags":ALLOW_LARGER })
|
||||
add_setting(
|
||||
{
|
||||
"name": "strength",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 33,
|
||||
"unit": "%",
|
||||
"range": Vector3(1, 100, 1),
|
||||
"flags": ALLOW_LARGER
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"height", "type":SettingType.SLIDER, "list":main_list, "default":20,
|
||||
"unit":"m", "range":Vector3(-500, 500, 0.1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting({ "name":"height_picker", "type":SettingType.PICKER, "list":main_list,
|
||||
"default":Terrain3DEditor.HEIGHT, "flags":NO_LABEL })
|
||||
add_setting(
|
||||
{
|
||||
"name": "height",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 20,
|
||||
"unit": "m",
|
||||
"range": Vector3(-500, 500, 0.1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "height_picker",
|
||||
"type": SettingType.PICKER,
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.HEIGHT,
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"color", "type":SettingType.COLOR_SELECT, "list":main_list,
|
||||
"default":Color.WHITE, "flags":ADD_SEPARATOR })
|
||||
add_setting({ "name":"color_picker", "type":SettingType.PICKER, "list":main_list,
|
||||
"default":Terrain3DEditor.COLOR, "flags":NO_LABEL })
|
||||
add_setting(
|
||||
{
|
||||
"name": "color",
|
||||
"type": SettingType.COLOR_SELECT,
|
||||
"list": main_list,
|
||||
"default": Color.WHITE,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "color_picker",
|
||||
"type": SettingType.PICKER,
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.COLOR,
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"roughness", "type":SettingType.SLIDER, "list":main_list, "default":-65,
|
||||
"unit":"%", "range":Vector3(-100, 100, 1), "flags":ADD_SEPARATOR })
|
||||
add_setting({ "name":"roughness_picker", "type":SettingType.PICKER, "list":main_list,
|
||||
"default":Terrain3DEditor.ROUGHNESS, "flags":NO_LABEL })
|
||||
add_setting(
|
||||
{
|
||||
"name": "roughness",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": -65,
|
||||
"unit": "%",
|
||||
"range": Vector3(-100, 100, 1),
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "roughness_picker",
|
||||
"type": SettingType.PICKER,
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.ROUGHNESS,
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"enable_texture", "label":"Texture", "type":SettingType.CHECKBOX,
|
||||
"list":main_list, "default":true, "flags":ADD_SEPARATOR })
|
||||
add_setting(
|
||||
{
|
||||
"name": "enable_texture",
|
||||
"label": "Texture",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": true,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"texture_filter", "label":"Texture Filter", "type":SettingType.CHECKBOX,
|
||||
"list":main_list, "default":false, "flags":ADD_SEPARATOR })
|
||||
add_setting(
|
||||
{
|
||||
"name": "texture_filter",
|
||||
"label": "Texture Filter",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": false,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"margin", "type":SettingType.SLIDER, "list":main_list, "default":0,
|
||||
"unit":"", "range":Vector3(-50, 50, 1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting(
|
||||
{
|
||||
"name": "margin",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 0,
|
||||
"unit": "",
|
||||
"range": Vector3(-50, 50, 1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
|
||||
# Slope painting filter
|
||||
add_setting({ "name":"slope", "type":SettingType.DOUBLE_SLIDER, "list":main_list, "default":Vector2(0, 90),
|
||||
"unit":"°", "range":Vector3(0, 90, 1), "flags":ADD_SEPARATOR })
|
||||
add_setting(
|
||||
{
|
||||
"name": "slope",
|
||||
"type": SettingType.DOUBLE_SLIDER,
|
||||
"list": main_list,
|
||||
"default": Vector2(0, 90),
|
||||
"unit": "°",
|
||||
"range": Vector3(0, 90, 1),
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"enable_angle", "label":"Angle", "type":SettingType.CHECKBOX,
|
||||
"list":main_list, "default":true, "flags":ADD_SEPARATOR })
|
||||
add_setting({ "name":"angle", "type":SettingType.SLIDER, "list":main_list, "default":0,
|
||||
"unit":"%", "range":Vector3(0, 337.5, 22.5), "flags":NO_LABEL })
|
||||
add_setting({ "name":"angle_picker", "type":SettingType.PICKER, "list":main_list,
|
||||
"default":Terrain3DEditor.ANGLE, "flags":NO_LABEL })
|
||||
add_setting({ "name":"dynamic_angle", "label":"Dynamic", "type":SettingType.CHECKBOX,
|
||||
"list":main_list, "default":false, "flags":ADD_SPACER })
|
||||
add_setting(
|
||||
{
|
||||
"name": "enable_angle",
|
||||
"label": "Angle",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": true,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "angle",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 0,
|
||||
"unit": "%",
|
||||
"range": Vector3(0, 337.5, 22.5),
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "angle_picker",
|
||||
"type": SettingType.PICKER,
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.ANGLE,
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "dynamic_angle",
|
||||
"label": "Dynamic",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": false,
|
||||
"flags": ADD_SPACER
|
||||
}
|
||||
)
|
||||
|
||||
add_setting({ "name":"enable_scale", "label":"Scale", "type":SettingType.CHECKBOX,
|
||||
"list":main_list, "default":true, "flags":ADD_SEPARATOR })
|
||||
add_setting({ "name":"scale", "label":"±", "type":SettingType.SLIDER, "list":main_list, "default":0,
|
||||
"unit":"%", "range":Vector3(-60, 80, 20), "flags":NO_LABEL })
|
||||
add_setting({ "name":"scale_picker", "type":SettingType.PICKER, "list":main_list,
|
||||
"default":Terrain3DEditor.SCALE, "flags":NO_LABEL })
|
||||
add_setting(
|
||||
{
|
||||
"name": "enable_scale",
|
||||
"label": "Scale",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": true,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "scale",
|
||||
"label": "±",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": main_list,
|
||||
"default": 0,
|
||||
"unit": "%",
|
||||
"range": Vector3(-60, 80, 20),
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "scale_picker",
|
||||
"type": SettingType.PICKER,
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.SCALE,
|
||||
"flags": NO_LABEL
|
||||
}
|
||||
)
|
||||
|
||||
## Slope sculpting brush
|
||||
add_setting({ "name":"gradient_points", "type":SettingType.MULTI_PICKER, "label":"Points",
|
||||
"list":main_list, "default":Terrain3DEditor.SCULPT, "flags":ADD_SEPARATOR })
|
||||
add_setting({ "name":"drawable", "type":SettingType.CHECKBOX, "list":main_list, "default":false,
|
||||
"flags":ADD_SEPARATOR })
|
||||
add_setting(
|
||||
{
|
||||
"name": "gradient_points",
|
||||
"type": SettingType.MULTI_PICKER,
|
||||
"label": "Points",
|
||||
"list": main_list,
|
||||
"default": Terrain3DEditor.SCULPT,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "drawable",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": false,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
settings["drawable"].toggled.connect(_on_drawable_toggled)
|
||||
|
||||
## Instancer
|
||||
height_list = create_submenu(main_list, "Height", Layout.VERTICAL)
|
||||
add_setting({ "name":"height_offset", "type":SettingType.SLIDER, "list":height_list, "default":0,
|
||||
"unit":"m", "range":Vector3(-10, 10, 0.05), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting({ "name":"random_height", "label":"Random Height ±", "type":SettingType.SLIDER,
|
||||
"list":height_list, "default":0, "unit":"m", "range":Vector3(0, 10, 0.05),
|
||||
"flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting(
|
||||
{
|
||||
"name": "height_offset",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": height_list,
|
||||
"default": 0,
|
||||
"unit": "m",
|
||||
"range": Vector3(-10, 10, 0.05),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_height",
|
||||
"label": "Random Height ±",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": height_list,
|
||||
"default": 0,
|
||||
"unit": "m",
|
||||
"range": Vector3(0, 10, 0.05),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
|
||||
scale_list = create_submenu(main_list, "Scale", Layout.VERTICAL)
|
||||
add_setting({ "name":"fixed_scale", "type":SettingType.SLIDER, "list":scale_list, "default":100,
|
||||
"unit":"%", "range":Vector3(1, 1000, 1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting({ "name":"random_scale", "label":"Random Scale ±", "type":SettingType.SLIDER, "list":scale_list,
|
||||
"default":20, "unit":"%", "range":Vector3(0, 99, 1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting(
|
||||
{
|
||||
"name": "fixed_scale",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": scale_list,
|
||||
"default": 100,
|
||||
"unit": "%",
|
||||
"range": Vector3(1, 1000, 1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_scale",
|
||||
"label": "Random Scale ±",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": scale_list,
|
||||
"default": 20,
|
||||
"unit": "%",
|
||||
"range": Vector3(0, 99, 1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
|
||||
rotation_list = create_submenu(main_list, "Rotation", Layout.VERTICAL)
|
||||
add_setting({ "name":"fixed_spin", "label":"Fixed Spin (Around Y)", "type":SettingType.SLIDER, "list":rotation_list,
|
||||
"default":0, "unit":"°", "range":Vector3(0, 360, 1) })
|
||||
add_setting({ "name":"random_spin", "type":SettingType.SLIDER, "list":rotation_list, "default":360,
|
||||
"unit":"°", "range":Vector3(0, 360, 1) })
|
||||
add_setting({ "name":"fixed_tilt", "label":"Fixed Tilt", "type":SettingType.SLIDER, "list":rotation_list,
|
||||
"default":0, "unit":"°", "range":Vector3(-85, 85, 1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting({ "name":"random_tilt", "label":"Random Tilt ±", "type":SettingType.SLIDER, "list":rotation_list,
|
||||
"default":10, "unit":"°", "range":Vector3(0, 85, 1), "flags":ALLOW_OUT_OF_BOUNDS })
|
||||
add_setting({ "name":"align_to_normal", "type":SettingType.CHECKBOX, "list":rotation_list, "default":false })
|
||||
add_setting(
|
||||
{
|
||||
"name": "fixed_spin",
|
||||
"label": "Fixed Spin (Around Y)",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": rotation_list,
|
||||
"default": 0,
|
||||
"unit": "°",
|
||||
"range": Vector3(0, 360, 1)
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_spin",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": rotation_list,
|
||||
"default": 360,
|
||||
"unit": "°",
|
||||
"range": Vector3(0, 360, 1)
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "fixed_tilt",
|
||||
"label": "Fixed Tilt",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": rotation_list,
|
||||
"default": 0,
|
||||
"unit": "°",
|
||||
"range": Vector3(-85, 85, 1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_tilt",
|
||||
"label": "Random Tilt ±",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": rotation_list,
|
||||
"default": 10,
|
||||
"unit": "°",
|
||||
"range": Vector3(0, 85, 1),
|
||||
"flags": ALLOW_OUT_OF_BOUNDS
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "align_to_normal",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": rotation_list,
|
||||
"default": false
|
||||
}
|
||||
)
|
||||
|
||||
color_list = create_submenu(main_list, "Color", Layout.VERTICAL)
|
||||
add_setting({ "name":"vertex_color", "type":SettingType.COLOR_SELECT, "list":color_list,
|
||||
"default":Color.WHITE })
|
||||
add_setting({ "name":"random_hue", "label":"Random Hue Shift ±", "type":SettingType.SLIDER,
|
||||
"list":color_list, "default":0, "unit":"°", "range":Vector3(0, 360, 1) })
|
||||
add_setting({ "name":"random_darken", "type":SettingType.SLIDER, "list":color_list, "default":50,
|
||||
"unit":"%", "range":Vector3(0, 100, 1) })
|
||||
add_setting(
|
||||
{
|
||||
"name": "vertex_color",
|
||||
"type": SettingType.COLOR_SELECT,
|
||||
"list": color_list,
|
||||
"default": Color.WHITE
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_hue",
|
||||
"label": "Random Hue Shift ±",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": color_list,
|
||||
"default": 0,
|
||||
"unit": "°",
|
||||
"range": Vector3(0, 360, 1)
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "random_darken",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": color_list,
|
||||
"default": 50,
|
||||
"unit": "%",
|
||||
"range": Vector3(0, 100, 1)
|
||||
}
|
||||
)
|
||||
#add_setting({ "name":"blend_mode", "type":SettingType.OPTION, "list":color_list, "default":0,
|
||||
#"range":Vector3(0, 3, 1) })
|
||||
|
||||
if DisplayServer.is_touchscreen_available():
|
||||
add_setting({ "name":"invert", "label":"Invert", "type":SettingType.CHECKBOX, "list":main_list, "default":false, "flags":ADD_SEPARATOR })
|
||||
add_setting(
|
||||
{
|
||||
"name": "invert",
|
||||
"label": "Invert",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": main_list,
|
||||
"default": false,
|
||||
"flags": ADD_SEPARATOR
|
||||
}
|
||||
)
|
||||
|
||||
var spacer: Control = Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
@@ -166,22 +457,67 @@ func _ready() -> void:
|
||||
|
||||
## Advanced Settings Menu
|
||||
advanced_list = create_submenu(main_list, "", Layout.VERTICAL, false)
|
||||
add_setting({ "name":"auto_regions", "label":"Add regions while sculpting", "type":SettingType.CHECKBOX,
|
||||
"list":advanced_list, "default":true })
|
||||
add_setting({ "name":"align_to_view", "type":SettingType.CHECKBOX, "list":advanced_list,
|
||||
"default":true })
|
||||
add_setting({ "name":"show_cursor_while_painting", "type":SettingType.CHECKBOX, "list":advanced_list,
|
||||
"default":true })
|
||||
add_setting(
|
||||
{
|
||||
"name": "auto_regions",
|
||||
"label": "Add regions while sculpting",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": advanced_list,
|
||||
"default": true
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "align_to_view",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": advanced_list,
|
||||
"default": true
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "show_cursor_while_painting",
|
||||
"type": SettingType.CHECKBOX,
|
||||
"list": advanced_list,
|
||||
"default": true
|
||||
}
|
||||
)
|
||||
advanced_list.add_child(HSeparator.new(), true)
|
||||
add_setting({ "name":"gamma", "type":SettingType.SLIDER, "list":advanced_list, "default":1.0,
|
||||
"unit":"γ", "range":Vector3(0.1, 2.0, 0.01) })
|
||||
add_setting({ "name":"jitter", "type":SettingType.SLIDER, "list":advanced_list, "default":50,
|
||||
"unit":"%", "range":Vector3(0, 100, 1) })
|
||||
add_setting({ "name":"crosshair_threshold", "type":SettingType.SLIDER, "list":advanced_list, "default":16.,
|
||||
"unit":"m", "range":Vector3(0, 200, 1) })
|
||||
add_setting(
|
||||
{
|
||||
"name": "gamma",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": advanced_list,
|
||||
"default": 1.0,
|
||||
"unit": "γ",
|
||||
"range": Vector3(0.1, 2.0, 0.01)
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "jitter",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": advanced_list,
|
||||
"default": 50,
|
||||
"unit": "%",
|
||||
"range": Vector3(0, 100, 1)
|
||||
}
|
||||
)
|
||||
add_setting(
|
||||
{
|
||||
"name": "crosshair_threshold",
|
||||
"type": SettingType.SLIDER,
|
||||
"list": advanced_list,
|
||||
"default": 16.,
|
||||
"unit": "m",
|
||||
"range": Vector3(0, 200, 1)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func create_submenu(p_parent: Control, p_button_name: String, p_layout: Layout, p_hover_pop: bool = true) -> Container:
|
||||
func create_submenu(
|
||||
p_parent: Control, p_button_name: String, p_layout: Layout, p_hover_pop: bool = true
|
||||
) -> Container:
|
||||
var menu_button: Button = Button.new()
|
||||
if p_button_name.is_empty():
|
||||
menu_button.icon = get_theme_icon("GuiTabMenuHl", "EditorIcons")
|
||||
@@ -204,7 +540,8 @@ func create_submenu(p_parent: Control, p_button_name: String, p_layout: Layout,
|
||||
|
||||
submenu.mouse_entered.connect(func(): submenu.set_meta("mouse_entered", true))
|
||||
|
||||
submenu.mouse_exited.connect(func():
|
||||
submenu.mouse_exited.connect(
|
||||
func():
|
||||
# On mouse_exit, hide popup unless LineEdit focused
|
||||
var focused_element: Control = submenu.gui_get_focus_owner()
|
||||
if not focused_element is LineEdit:
|
||||
@@ -212,7 +549,8 @@ func create_submenu(p_parent: Control, p_button_name: String, p_layout: Layout,
|
||||
submenu.set_meta("mouse_entered", false)
|
||||
return
|
||||
|
||||
focused_element.focus_exited.connect(func():
|
||||
focused_element.focus_exited.connect(
|
||||
func():
|
||||
# Close submenu once lineedit loses focus
|
||||
if not submenu.get_meta("mouse_entered"):
|
||||
_on_show_submenu(false, menu_button)
|
||||
@@ -221,7 +559,7 @@ func create_submenu(p_parent: Control, p_button_name: String, p_layout: Layout,
|
||||
)
|
||||
|
||||
var sublist: Container
|
||||
match(p_layout):
|
||||
match p_layout:
|
||||
Layout.GRID:
|
||||
sublist = GridContainer.new()
|
||||
Layout.VERTICAL:
|
||||
@@ -242,12 +580,14 @@ func _on_show_submenu(p_toggled: bool, p_button: Button) -> void:
|
||||
return
|
||||
|
||||
# Hide menu if mouse is not in button or panel
|
||||
var button_rect: Rect2 = Rect2(p_button.get_screen_transform().origin, p_button.get_global_rect().size)
|
||||
var button_rect: Rect2 = Rect2(
|
||||
p_button.get_screen_transform().origin, p_button.get_global_rect().size
|
||||
)
|
||||
var in_button: bool = button_rect.has_point(DisplayServer.mouse_get_position())
|
||||
var popup: PopupPanel = p_button.get_child(0)
|
||||
var popup_rect: Rect2 = Rect2(popup.position, popup.size)
|
||||
var in_popup: bool = popup_rect.has_point(DisplayServer.mouse_get_position())
|
||||
if not p_toggled and ( in_button or in_popup ):
|
||||
if not p_toggled and (in_button or in_popup):
|
||||
return
|
||||
|
||||
# Hide all submenus before possibly enabling the current one
|
||||
@@ -255,7 +595,7 @@ func _on_show_submenu(p_toggled: bool, p_button: Button) -> void:
|
||||
popup.set_visible(p_toggled)
|
||||
var popup_pos: Vector2 = p_button.get_screen_transform().origin
|
||||
popup_pos.y -= popup.size.y
|
||||
if popup.get_child_count()>0 and popup.get_child(0) == advanced_list:
|
||||
if popup.get_child_count() > 0 and popup.get_child(0) == advanced_list:
|
||||
popup_pos.x -= popup.size.x - p_button.size.x
|
||||
popup.set_position(popup_pos)
|
||||
|
||||
@@ -352,7 +692,11 @@ func _on_picked(p_type: Terrain3DEditor.Tool, p_color: Color, p_global_position:
|
||||
Terrain3DEditor.ROUGHNESS:
|
||||
# This converts 0,1 to -100,100
|
||||
# It also quantizes explicitly so picked values matches painted values
|
||||
settings["roughness"].value = round(200. * float(int(p_color.a * 255.) / 255. - .5)) if not is_nan(p_color.r) else 0.
|
||||
settings["roughness"].value = (
|
||||
round(200. * float(int(p_color.a * 255.) / 255. - .5))
|
||||
if not is_nan(p_color.r)
|
||||
else 0.
|
||||
)
|
||||
Terrain3DEditor.ANGLE:
|
||||
settings["angle"].value = p_color.r
|
||||
Terrain3DEditor.SCALE:
|
||||
@@ -365,7 +709,9 @@ func _on_point_pick(p_type: Terrain3DEditor.Tool, p_name: String) -> void:
|
||||
emit_signal("picking", p_type, _on_point_picked.bind(p_name))
|
||||
|
||||
|
||||
func _on_point_picked(p_type: Terrain3DEditor.Tool, p_color: Color, p_global_position: Vector3, p_name: String) -> void:
|
||||
func _on_point_picked(
|
||||
p_type: Terrain3DEditor.Tool, p_color: Color, p_global_position: Vector3, p_name: String
|
||||
) -> void:
|
||||
assert(p_type == Terrain3DEditor.SCULPT)
|
||||
var point: Vector3 = p_global_position
|
||||
point.y = p_color.r
|
||||
@@ -404,11 +750,15 @@ func add_setting(p_args: Dictionary) -> void:
|
||||
SettingType.CHECKBOX:
|
||||
var checkbox := CheckBox.new()
|
||||
if !(p_flags & NO_SAVE):
|
||||
checkbox.set_pressed_no_signal(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
|
||||
checkbox.toggled.connect( (
|
||||
func(value, path):
|
||||
plugin.set_setting(path, value)
|
||||
).bind(ES_TOOL_SETTINGS + p_name) )
|
||||
checkbox.set_pressed_no_signal(
|
||||
plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default)
|
||||
)
|
||||
checkbox.toggled.connect(
|
||||
(
|
||||
(func(value, path): plugin.set_setting(path, value))
|
||||
. bind(ES_TOOL_SETTINGS + p_name)
|
||||
)
|
||||
)
|
||||
else:
|
||||
checkbox.set_pressed_no_signal(p_default)
|
||||
checkbox.pressed.connect(_on_setting_changed)
|
||||
@@ -422,10 +772,12 @@ func add_setting(p_args: Dictionary) -> void:
|
||||
picker.get_picker().set_color_mode(ColorPicker.MODE_HSV)
|
||||
if !(p_flags & NO_SAVE):
|
||||
picker.set_pick_color(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
|
||||
picker.color_changed.connect( (
|
||||
func(value, path):
|
||||
plugin.set_setting(path, value)
|
||||
).bind(ES_TOOL_SETTINGS + p_name) )
|
||||
picker.color_changed.connect(
|
||||
(
|
||||
(func(value, path): plugin.set_setting(path, value))
|
||||
. bind(ES_TOOL_SETTINGS + p_name)
|
||||
)
|
||||
)
|
||||
else:
|
||||
picker.set_pick_color(p_default)
|
||||
picker.color_changed.connect(_on_setting_changed)
|
||||
@@ -505,10 +857,12 @@ func add_setting(p_args: Dictionary) -> void:
|
||||
|
||||
if !(p_flags & NO_SAVE):
|
||||
slider.set_value(plugin.get_setting(ES_TOOL_SETTINGS + p_name, p_default))
|
||||
slider.value_changed.connect( (
|
||||
func(value, path):
|
||||
plugin.set_setting(path, value)
|
||||
).bind(ES_TOOL_SETTINGS + p_name) )
|
||||
slider.value_changed.connect(
|
||||
(
|
||||
(func(value, path): plugin.set_setting(path, value))
|
||||
. bind(ES_TOOL_SETTINGS + p_name)
|
||||
)
|
||||
)
|
||||
else:
|
||||
slider.set_value(p_default)
|
||||
|
||||
@@ -569,7 +923,10 @@ func get_setting(p_setting: String) -> Variant:
|
||||
value = object.get_value()
|
||||
# Adjust widths of all sliders on update of values
|
||||
var digits: float = count_digits(value)
|
||||
var width: float = clamp( (1 + count_digits(value)) * 19., 50, 80) * clamp(EditorInterface.get_editor_scale(), .9, 2)
|
||||
var width: float = (
|
||||
clamp((1 + count_digits(value)) * 19., 50, 80)
|
||||
* clamp(EditorInterface.get_editor_scale(), .9, 2)
|
||||
)
|
||||
object.set_custom_minimum_size(Vector2(width, 0))
|
||||
elif object is DoubleSlider:
|
||||
value = object.get_value()
|
||||
@@ -644,7 +1001,7 @@ func _generate_brush_texture(p_btn: Button) -> void:
|
||||
img = img.duplicate()
|
||||
img.resize(1024, 1024, Image.INTERPOLATE_CUBIC)
|
||||
var tex: ImageTexture = ImageTexture.create_from_image(img)
|
||||
selected_brush_imgs = [ img, tex ]
|
||||
selected_brush_imgs = [img, tex]
|
||||
|
||||
|
||||
func _on_drawable_toggled(p_button_pressed: bool) -> void:
|
||||
@@ -676,18 +1033,17 @@ func count_digits(p_value: float) -> int:
|
||||
var count: int = 1
|
||||
for i in range(5, 0, -1):
|
||||
if abs(p_value) >= pow(10, i):
|
||||
count = i+1
|
||||
count = i + 1
|
||||
break
|
||||
if p_value - floor(p_value) >= .1:
|
||||
count += 1 # For the decimal
|
||||
if p_value*10 - floor(p_value*10.) >= .1:
|
||||
if p_value * 10 - floor(p_value * 10.) >= .1:
|
||||
count += 1
|
||||
if p_value*100 - floor(p_value*100.) >= .1:
|
||||
if p_value * 100 - floor(p_value * 100.) >= .1:
|
||||
count += 1
|
||||
if p_value*1000 - floor(p_value*1000.) >= .1:
|
||||
if p_value * 1000 - floor(p_value * 1000.) >= .1:
|
||||
count += 1
|
||||
# Negative sign
|
||||
if p_value < 0:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@@ -33,61 +33,151 @@ func _ready() -> void:
|
||||
add_tool_group.pressed.connect(_on_tool_selected)
|
||||
sub_tool_group.pressed.connect(_on_tool_selected)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.REGION,
|
||||
"add_text":"Add Region (E)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_REGION_ADD,
|
||||
"sub_text":"Remove Region", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_REGION_REMOVE })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.REGION,
|
||||
"add_text": "Add Region (E)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_REGION_ADD,
|
||||
"sub_text": "Remove Region",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT,
|
||||
"sub_icon": ICON_REGION_REMOVE
|
||||
}
|
||||
)
|
||||
|
||||
add_child(HSeparator.new())
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.SCULPT,
|
||||
"add_text":"Raise (R)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HEIGHT_ADD,
|
||||
"sub_text":"Lower (R)", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_HEIGHT_SUB })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.SCULPT,
|
||||
"add_text": "Raise (R)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_HEIGHT_ADD,
|
||||
"sub_text": "Lower (R)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT,
|
||||
"sub_icon": ICON_HEIGHT_SUB
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.SCULPT,
|
||||
"add_text":"Smooth (Shift)", "add_op":Terrain3DEditor.AVERAGE, "add_icon":ICON_HEIGHT_SMOOTH })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.SCULPT,
|
||||
"add_text": "Smooth (Shift)",
|
||||
"add_op": Terrain3DEditor.AVERAGE,
|
||||
"add_icon": ICON_HEIGHT_SMOOTH
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.HEIGHT,
|
||||
"add_text":"Height (H)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HEIGHT_FLAT,
|
||||
"sub_text":"Height (H)", "sub_op":Terrain3DEditor.SUBTRACT, "sub_icon":ICON_HEIGHT_FLAT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.HEIGHT,
|
||||
"add_text": "Height (H)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_HEIGHT_FLAT,
|
||||
"sub_text": "Height (H)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT,
|
||||
"sub_icon": ICON_HEIGHT_FLAT
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.SCULPT,
|
||||
"add_text":"Slope (S)", "add_op":Terrain3DEditor.GRADIENT, "add_icon":ICON_HEIGHT_SLOPE })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.SCULPT,
|
||||
"add_text": "Slope (S)",
|
||||
"add_op": Terrain3DEditor.GRADIENT,
|
||||
"add_icon": ICON_HEIGHT_SLOPE
|
||||
}
|
||||
)
|
||||
|
||||
add_child(HSeparator.new())
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.TEXTURE,
|
||||
"add_text":"Paint Texture (B)", "add_op":Terrain3DEditor.REPLACE, "add_icon":ICON_PAINT_TEXTURE })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.TEXTURE,
|
||||
"add_text": "Paint Texture (B)",
|
||||
"add_op": Terrain3DEditor.REPLACE,
|
||||
"add_icon": ICON_PAINT_TEXTURE
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.TEXTURE,
|
||||
"add_text":"Spray Texture (V)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_SPRAY_TEXTURE })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.TEXTURE,
|
||||
"add_text": "Spray Texture (V)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_SPRAY_TEXTURE
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.AUTOSHADER,
|
||||
"add_text":"Paint Autoshader (A)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_AUTOSHADER,
|
||||
"sub_text":"Disable Autoshader (A)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.AUTOSHADER,
|
||||
"add_text": "Paint Autoshader (A)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_AUTOSHADER,
|
||||
"sub_text": "Disable Autoshader (A)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
add_child(HSeparator.new())
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.COLOR,
|
||||
"add_text":"Paint Color (C)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_COLOR,
|
||||
"sub_text":"Remove Color (C)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.COLOR,
|
||||
"add_text": "Paint Color (C)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_COLOR,
|
||||
"sub_text": "Remove Color (C)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.ROUGHNESS,
|
||||
"add_text":"Paint Wetness (W)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_WETNESS,
|
||||
"sub_text":"Remove Wetness (W)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.ROUGHNESS,
|
||||
"add_text": "Paint Wetness (W)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_WETNESS,
|
||||
"sub_text": "Remove Wetness (W)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
add_child(HSeparator.new())
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.HOLES,
|
||||
"add_text":"Add Holes (X)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_HOLES,
|
||||
"sub_text":"Remove Holes (X)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.HOLES,
|
||||
"add_text": "Add Holes (X)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_HOLES,
|
||||
"sub_text": "Remove Holes (X)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.NAVIGATION,
|
||||
"add_text":"Paint Navigable Area (N)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_NAVIGATION,
|
||||
"sub_text":"Remove Navigable Area (N)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.NAVIGATION,
|
||||
"add_text": "Paint Navigable Area (N)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_NAVIGATION,
|
||||
"sub_text": "Remove Navigable Area (N)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
add_tool_button({ "tool":Terrain3DEditor.INSTANCER,
|
||||
"add_text":"Instance Meshes (I)", "add_op":Terrain3DEditor.ADD, "add_icon":ICON_INSTANCER,
|
||||
"sub_text":"Remove Meshes (I)", "sub_op":Terrain3DEditor.SUBTRACT })
|
||||
add_tool_button(
|
||||
{
|
||||
"tool": Terrain3DEditor.INSTANCER,
|
||||
"add_text": "Instance Meshes (I)",
|
||||
"add_op": Terrain3DEditor.ADD,
|
||||
"add_icon": ICON_INSTANCER,
|
||||
"sub_text": "Remove Meshes (I)",
|
||||
"sub_op": Terrain3DEditor.SUBTRACT
|
||||
}
|
||||
)
|
||||
|
||||
# Select first button
|
||||
var buttons: Array[BaseButton] = add_tool_group.get_buttons()
|
||||
@@ -98,7 +188,7 @@ func _ready() -> void:
|
||||
func add_tool_button(p_params: Dictionary) -> void:
|
||||
# Additive button
|
||||
var button := Button.new()
|
||||
var name_str: String = p_params.get("add_text", "blank").get_slice('(', 0).to_pascal_case()
|
||||
var name_str: String = p_params.get("add_text", "blank").get_slice("(", 0).to_pascal_case()
|
||||
button.set_name(name_str)
|
||||
button.set_meta("Tool", p_params.get("tool", 0))
|
||||
button.set_meta("Operation", p_params.get("add_op", 0))
|
||||
@@ -116,7 +206,7 @@ func add_tool_button(p_params: Dictionary) -> void:
|
||||
var button2: Button
|
||||
if p_params.has("sub_text"):
|
||||
button2 = Button.new()
|
||||
name_str = p_params.get("sub_text", "blank").get_slice('(', 0).to_pascal_case()
|
||||
name_str = p_params.get("sub_text", "blank").get_slice("(", 0).to_pascal_case()
|
||||
button2.set_name(name_str)
|
||||
button2.set_meta("Tool", p_params.get("tool", 0))
|
||||
button2.set_meta("Operation", p_params.get("sub_op", 0))
|
||||
@@ -151,4 +241,8 @@ func _on_tool_selected(p_button: BaseButton) -> void:
|
||||
var id: int = p_button.get_meta("ID", -2)
|
||||
for button in change_group.get_buttons():
|
||||
button.set_pressed_no_signal(button.get_meta("ID", -1) == id)
|
||||
emit_signal("tool_changed", p_button.get_meta("Tool", Terrain3DEditor.TOOL_MAX), p_button.get_meta("Operation", Terrain3DEditor.OP_MAX))
|
||||
emit_signal(
|
||||
"tool_changed",
|
||||
p_button.get_meta("Tool", Terrain3DEditor.TOOL_MAX),
|
||||
p_button.get_meta("Operation", Terrain3DEditor.OP_MAX)
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
@tool
|
||||
extends Terrain3D
|
||||
|
||||
|
||||
@export var clear_all: bool = false : set = reset_settings
|
||||
@export var clear_terrain: bool = false : set = reset_terrain
|
||||
@export var update_height_range: bool = false : set = update_heights
|
||||
@export var clear_all: bool = false:
|
||||
set = reset_settings
|
||||
@export var clear_terrain: bool = false:
|
||||
set = reset_terrain
|
||||
@export var update_height_range: bool = false:
|
||||
set = update_heights
|
||||
|
||||
|
||||
func reset_settings(p_value) -> void:
|
||||
@@ -27,7 +29,7 @@ func reset_settings(p_value) -> void:
|
||||
|
||||
func reset_terrain(p_value) -> void:
|
||||
data_directory = ""
|
||||
for region:Terrain3DRegion in data.get_regions_active():
|
||||
for region: Terrain3DRegion in data.get_regions_active():
|
||||
data.remove_region(region, false)
|
||||
data.update_maps(Terrain3DRegion.TYPE_MAX, true, false)
|
||||
|
||||
@@ -42,15 +44,19 @@ func update_heights(p_value) -> void:
|
||||
@export_global_file var height_file_name: String = ""
|
||||
@export_global_file var control_file_name: String = ""
|
||||
@export_global_file var color_file_name: String = ""
|
||||
@export var import_position: Vector2i = Vector2i(0, 0) : set = set_import_position
|
||||
@export var import_position: Vector2i = Vector2i(0, 0):
|
||||
set = set_import_position
|
||||
@export var import_scale: float = 1.0
|
||||
@export var height_offset: float = 0.0
|
||||
@export var r16_range: Vector2 = Vector2(0, 1)
|
||||
@export var r16_size: Vector2i = Vector2i(1024, 1024) : set = set_r16_size
|
||||
@export var run_import: bool = false : set = start_import
|
||||
@export var r16_size: Vector2i = Vector2i(1024, 1024):
|
||||
set = set_r16_size
|
||||
@export var run_import: bool = false:
|
||||
set = start_import
|
||||
|
||||
@export_dir var destination_directory: String = ""
|
||||
@export var save_to_disk: bool = false : set = save_data
|
||||
@export var save_to_disk: bool = false:
|
||||
set = save_data
|
||||
|
||||
|
||||
func set_import_position(p_value: Vector2i) -> void:
|
||||
@@ -65,14 +71,21 @@ func set_r16_size(p_value: Vector2i) -> void:
|
||||
|
||||
func start_import(p_value: bool) -> void:
|
||||
if p_value:
|
||||
print("Terrain3DImporter: Importing files:\n\t%s\n\t%s\n\t%s" % [ height_file_name, control_file_name, color_file_name])
|
||||
print(
|
||||
(
|
||||
"Terrain3DImporter: Importing files:\n\t%s\n\t%s\n\t%s"
|
||||
% [height_file_name, control_file_name, color_file_name]
|
||||
)
|
||||
)
|
||||
|
||||
var imported_images: Array[Image]
|
||||
imported_images.resize(Terrain3DRegion.TYPE_MAX)
|
||||
var min_max := Vector2(0, 1)
|
||||
var img: Image
|
||||
if height_file_name:
|
||||
img = Terrain3DUtil.load_image(height_file_name, ResourceLoader.CACHE_MODE_IGNORE, r16_range, r16_size)
|
||||
img = Terrain3DUtil.load_image(
|
||||
height_file_name, ResourceLoader.CACHE_MODE_IGNORE, r16_range, r16_size
|
||||
)
|
||||
min_max = Terrain3DUtil.get_min_max(img)
|
||||
imported_images[Terrain3DRegion.TYPE_HEIGHT] = img
|
||||
if control_file_name:
|
||||
@@ -100,9 +113,10 @@ func save_data(p_value: bool) -> void:
|
||||
enum { TYPE_HEIGHT, TYPE_CONTROL, TYPE_COLOR }
|
||||
@export_enum("Height:0", "Control:1", "Color:2") var map_type: int = TYPE_HEIGHT
|
||||
@export var file_name_out: String = ""
|
||||
@export var run_export: bool = false : set = start_export
|
||||
@export var run_export: bool = false:
|
||||
set = start_export
|
||||
|
||||
|
||||
func start_export(p_value: bool) -> void:
|
||||
var err: int = data.export_image(file_name_out, map_type)
|
||||
print("Terrain3DImporter: Export error status: ", err, " ", error_string(err))
|
||||
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
# It should unload the regions, rename files, and reload them
|
||||
# Clear the script and resave your scene
|
||||
|
||||
|
||||
@tool
|
||||
extends Terrain3D
|
||||
|
||||
|
||||
@export var offset: Vector2i
|
||||
@export var run: bool = false : set = start_rename
|
||||
@export var run: bool = false:
|
||||
set = start_rename
|
||||
|
||||
|
||||
func start_rename(val: bool = false) -> void:
|
||||
@@ -36,17 +35,22 @@ func start_rename(val: bool = false) -> void:
|
||||
var region_loc: Vector2i = Terrain3DUtil.filename_to_location(file_name)
|
||||
var new_loc: Vector2i = region_loc + offset
|
||||
if new_loc.x < -16 or new_loc.x > 15 or new_loc.y < -16 or new_loc.y > 15:
|
||||
push_error("New location %.0v out of bounds for region %.0v. Aborting" % [ new_loc, region_loc ])
|
||||
push_error(
|
||||
(
|
||||
"New location %.0v out of bounds for region %.0v. Aborting"
|
||||
% [new_loc, region_loc]
|
||||
)
|
||||
)
|
||||
return
|
||||
var new_name: String = "tmp_" + Terrain3DUtil.location_to_filename(new_loc)
|
||||
dir.rename(file_name, new_name)
|
||||
affected_files.push_back(new_name)
|
||||
print("File: %s renamed to: %s" % [ file_name, new_name ])
|
||||
print("File: %s renamed to: %s" % [file_name, new_name])
|
||||
|
||||
for file_name in affected_files:
|
||||
var new_name: String = file_name.trim_prefix("tmp_")
|
||||
dir.rename(file_name, new_name)
|
||||
print("File: %s renamed to: %s" % [ file_name, new_name ])
|
||||
print("File: %s renamed to: %s" % [file_name, new_name])
|
||||
|
||||
data_directory = dir_name
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
extends Node3D
|
||||
class_name Terrain3DObjects
|
||||
|
||||
const TransformChangedNotifier: Script = preload("res://addons/terrain_3d/utils/transform_changed_notifier.gd")
|
||||
const TransformChangedNotifier: Script = preload(
|
||||
"res://addons/terrain_3d/utils/transform_changed_notifier.gd"
|
||||
)
|
||||
|
||||
const CHILD_HELPER_NAME: StringName = &"TransformChangedSignaller"
|
||||
const CHILD_HELPER_PATH: NodePath = ^"TransformChangedSignaller"
|
||||
@@ -45,7 +47,12 @@ func editor_setup(p_plugin) -> void:
|
||||
func get_terrain() -> Terrain3D:
|
||||
var terrain := instance_from_id(_terrain_id) as Terrain3D
|
||||
if not terrain or terrain.is_queued_for_deletion() or not terrain.is_inside_tree():
|
||||
var terrains: Array[Node] = Engine.get_singleton(&"EditorInterface").get_edited_scene_root().find_children("", "Terrain3D")
|
||||
var terrains: Array[Node] = (
|
||||
Engine
|
||||
. get_singleton(&"EditorInterface")
|
||||
. get_edited_scene_root()
|
||||
. find_children("", "Terrain3D")
|
||||
)
|
||||
if terrains.size() > 0:
|
||||
terrain = terrains[0]
|
||||
_terrain_id = terrain.get_instance_id() if terrain else 0
|
||||
@@ -143,8 +150,12 @@ func _on_child_transform_changed(p_node: Node3D) -> void:
|
||||
|
||||
# Make sure that when the user undo's the translation, the offset change gets undone too!
|
||||
_undo_redo.create_action("Translate", UndoRedo.MERGE_ALL)
|
||||
_undo_redo.add_do_method(self, &"_set_offset_and_position", p_node.get_instance_id(), new_offset, new_position)
|
||||
_undo_redo.add_undo_method(self, &"_set_offset_and_position", p_node.get_instance_id(), old_offset, old_position)
|
||||
_undo_redo.add_do_method(
|
||||
self, &"_set_offset_and_position", p_node.get_instance_id(), new_offset, new_position
|
||||
)
|
||||
_undo_redo.add_undo_method(
|
||||
self, &"_set_offset_and_position", p_node.get_instance_id(), old_offset, old_position
|
||||
)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ dest_files=["res://.godot/imported/tree-high.glb-7b56684fc38d48b551f6a2589ecabd5
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
@@ -40,3 +42,4 @@ materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=0
|
||||
|
||||
@@ -16,6 +16,8 @@ dest_files=["res://.godot/imported/tree.glb-5e87a640dbd9ffc2053ff1705fe52b99.scn
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
@@ -40,3 +42,4 @@ materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=0
|
||||
|
||||
@@ -22,7 +22,7 @@ func _input(p_event: InputEvent) -> void:
|
||||
return
|
||||
|
||||
|
||||
func rotate_camera(p_relative:Vector2) -> void:
|
||||
func rotate_camera(p_relative: Vector2) -> void:
|
||||
_camera_yaw.rotation.y -= p_relative.x * mouse_sensitivity
|
||||
_camera_yaw.orthonormalize()
|
||||
_camera_pitch.rotation.x += p_relative.y * mouse_sensitivity * CAMERA_RATIO * mouse_y_inversion
|
||||
|
||||
@@ -20,20 +20,22 @@ func _ready() -> void:
|
||||
func create_terrain() -> Terrain3D:
|
||||
# Create textures
|
||||
var green_gr := Gradient.new()
|
||||
green_gr.set_color(0, Color.from_hsv(100./360., .35, .3))
|
||||
green_gr.set_color(1, Color.from_hsv(120./360., .4, .37))
|
||||
green_gr.set_color(0, Color.from_hsv(100. / 360., .35, .3))
|
||||
green_gr.set_color(1, Color.from_hsv(120. / 360., .4, .37))
|
||||
var green_ta: Terrain3DTextureAsset = await create_texture_asset("Grass", green_gr, 1024)
|
||||
green_ta.uv_scale = 0.1
|
||||
green_ta.detiling_rotation = 0.1
|
||||
|
||||
var brown_gr := Gradient.new()
|
||||
brown_gr.set_color(0, Color.from_hsv(30./360., .4, .3))
|
||||
brown_gr.set_color(1, Color.from_hsv(30./360., .4, .4))
|
||||
brown_gr.set_color(0, Color.from_hsv(30. / 360., .4, .3))
|
||||
brown_gr.set_color(1, Color.from_hsv(30. / 360., .4, .4))
|
||||
var brown_ta: Terrain3DTextureAsset = await create_texture_asset("Dirt", brown_gr, 1024)
|
||||
brown_ta.uv_scale = 0.03
|
||||
green_ta.detiling_rotation = 0.1
|
||||
|
||||
var grass_ma: Terrain3DMeshAsset = create_mesh_asset("Grass", Color.from_hsv(120./360., .4, .37))
|
||||
var grass_ma: Terrain3DMeshAsset = create_mesh_asset(
|
||||
"Grass", Color.from_hsv(120. / 360., .4, .37)
|
||||
)
|
||||
|
||||
# Create a terrain
|
||||
var terrain := Terrain3D.new()
|
||||
@@ -77,7 +79,9 @@ func create_terrain() -> Terrain3D:
|
||||
return terrain
|
||||
|
||||
|
||||
func create_texture_asset(asset_name: String, gradient: Gradient, texture_size: int = 512) -> Terrain3DTextureAsset:
|
||||
func create_texture_asset(
|
||||
asset_name: String, gradient: Gradient, texture_size: int = 512
|
||||
) -> Terrain3DTextureAsset:
|
||||
# Create noise map
|
||||
var fnl := FastNoiseLite.new()
|
||||
fnl.frequency = 0.004
|
||||
|
||||
@@ -9,8 +9,11 @@ func _ready():
|
||||
$UI.player = $Player
|
||||
|
||||
# Load Sky3D into the demo environment if enabled
|
||||
if Engine.is_editor_hint() and has_node("Environment") and \
|
||||
Engine.get_singleton(&"EditorInterface").is_plugin_enabled("sky_3d"):
|
||||
if (
|
||||
Engine.is_editor_hint()
|
||||
and has_node("Environment")
|
||||
and Engine.get_singleton(&"EditorInterface").is_plugin_enabled("sky_3d")
|
||||
):
|
||||
$Environment.queue_free()
|
||||
var sky3d = load("res://addons/sky_3d/src/Sky3D.gd").new()
|
||||
sky3d.name = "Sky3D"
|
||||
@@ -19,5 +22,3 @@ func _ready():
|
||||
sky3d.owner = self
|
||||
sky3d.current_time = 10
|
||||
sky3d.enable_editor_time = false
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ func _process(p_delta: float) -> void:
|
||||
|
||||
|
||||
func is_on_nav_mesh() -> bool:
|
||||
var closest_point := NavigationServer3D.map_get_closest_point(nav_agent.get_navigation_map(), global_position)
|
||||
var closest_point := NavigationServer3D.map_get_closest_point(
|
||||
nav_agent.get_navigation_map(), global_position
|
||||
)
|
||||
return global_position.distance_squared_to(closest_point) < nav_agent.path_max_distance ** 2
|
||||
|
||||
|
||||
|
||||
+11
-11
@@ -2,7 +2,7 @@ extends CharacterBody3D
|
||||
|
||||
@export var MOVE_SPEED: float = 50.0
|
||||
@export var JUMP_SPEED: float = 2.0
|
||||
@export var first_person: bool = false :
|
||||
@export var first_person: bool = false:
|
||||
set(p_value):
|
||||
first_person = p_value
|
||||
if first_person:
|
||||
@@ -13,17 +13,17 @@ extends CharacterBody3D
|
||||
$Body.visible = true
|
||||
create_tween().tween_property($CameraManager/Arm, "spring_length", 6.0, .33)
|
||||
|
||||
@export var gravity_enabled: bool = true :
|
||||
@export var gravity_enabled: bool = true:
|
||||
set(p_value):
|
||||
gravity_enabled = p_value
|
||||
if not gravity_enabled:
|
||||
velocity.y = 0
|
||||
|
||||
@export var collision_enabled: bool = true :
|
||||
@export var collision_enabled: bool = true:
|
||||
set(p_value):
|
||||
collision_enabled = p_value
|
||||
$CollisionShapeBody.disabled = ! collision_enabled
|
||||
$CollisionShapeRay.disabled = ! collision_enabled
|
||||
$CollisionShapeBody.disabled = !collision_enabled
|
||||
$CollisionShapeRay.disabled = !collision_enabled
|
||||
|
||||
|
||||
func _physics_process(p_delta) -> void:
|
||||
@@ -50,9 +50,9 @@ func get_camera_relative_input() -> Vector3:
|
||||
if Input.is_key_pressed(KEY_S): # Backward
|
||||
input_dir += %Camera3D.global_transform.basis.z
|
||||
if Input.is_key_pressed(KEY_E) or Input.is_key_pressed(KEY_SPACE): # Up
|
||||
velocity.y += JUMP_SPEED + MOVE_SPEED*.016
|
||||
velocity.y += JUMP_SPEED + MOVE_SPEED * .016
|
||||
if Input.is_key_pressed(KEY_Q): # Down
|
||||
velocity.y -= JUMP_SPEED + MOVE_SPEED*.016
|
||||
velocity.y -= JUMP_SPEED + MOVE_SPEED * .016
|
||||
if Input.is_key_pressed(KEY_KP_ADD) or Input.is_key_pressed(KEY_EQUAL):
|
||||
MOVE_SPEED = clamp(MOVE_SPEED + .5, 5, 9999)
|
||||
if Input.is_key_pressed(KEY_KP_SUBTRACT) or Input.is_key_pressed(KEY_MINUS):
|
||||
@@ -70,12 +70,12 @@ func _input(p_event: InputEvent) -> void:
|
||||
elif p_event is InputEventKey:
|
||||
if p_event.pressed:
|
||||
if p_event.keycode == KEY_V:
|
||||
first_person = ! first_person
|
||||
first_person = !first_person
|
||||
elif p_event.keycode == KEY_G:
|
||||
gravity_enabled = ! gravity_enabled
|
||||
gravity_enabled = !gravity_enabled
|
||||
elif p_event.keycode == KEY_C:
|
||||
collision_enabled = ! collision_enabled
|
||||
collision_enabled = !collision_enabled
|
||||
|
||||
# Else if up/down released
|
||||
elif p_event.keycode in [ KEY_Q, KEY_E, KEY_SPACE ]:
|
||||
elif p_event.keycode in [KEY_Q, KEY_E, KEY_SPACE]:
|
||||
velocity.y = 0
|
||||
|
||||
@@ -2,11 +2,16 @@ extends Node
|
||||
|
||||
signal bake_finished
|
||||
|
||||
@export var enabled: bool = true : set = set_enabled
|
||||
@export var enter_cost: float = 0.0 : set = set_enter_cost
|
||||
@export var travel_cost: float = 1.0 : set = set_travel_cost
|
||||
@export_flags_3d_navigation var navigation_layers: int = 1 : set = set_navigation_layers
|
||||
@export var template: NavigationMesh : set = set_template
|
||||
@export var enabled: bool = true:
|
||||
set = set_enabled
|
||||
@export var enter_cost: float = 0.0:
|
||||
set = set_enter_cost
|
||||
@export var travel_cost: float = 1.0:
|
||||
set = set_travel_cost
|
||||
@export_flags_3d_navigation var navigation_layers: int = 1:
|
||||
set = set_navigation_layers
|
||||
@export var template: NavigationMesh:
|
||||
set = set_template
|
||||
@export var terrain: Terrain3D
|
||||
@export var player: Node3D
|
||||
@export var mesh_size := Vector3(256, 512, 256)
|
||||
@@ -16,7 +21,7 @@ signal bake_finished
|
||||
@export var log_timing: bool = false
|
||||
|
||||
var _scene_geometry: NavigationMeshSourceGeometryData3D
|
||||
var _current_center := Vector3(INF,INF,INF)
|
||||
var _current_center := Vector3(INF, INF, INF)
|
||||
|
||||
var _bake_task_id: int = -1
|
||||
var _bake_task_timer: float = 0.0
|
||||
@@ -112,7 +117,9 @@ func _process(p_delta: float) -> void:
|
||||
|
||||
func _rebake(p_center: Vector3) -> void:
|
||||
assert(template != null)
|
||||
_bake_task_id = WorkerThreadPool.add_task(_task_bake.bind(p_center), false, "RuntimeNavigationBaker")
|
||||
_bake_task_id = WorkerThreadPool.add_task(
|
||||
_task_bake.bind(p_center), false, "RuntimeNavigationBaker"
|
||||
)
|
||||
_bake_task_timer = 0.0
|
||||
_bake_cooldown_timer = bake_cooldown
|
||||
|
||||
|
||||
+7
-6
@@ -1,6 +1,5 @@
|
||||
extends Control
|
||||
|
||||
|
||||
var player: Node
|
||||
var visible_mode: int = 1
|
||||
|
||||
@@ -11,7 +10,7 @@ func _init() -> void:
|
||||
|
||||
func _process(p_delta) -> void:
|
||||
$Label.text = "FPS: %d\n" % Engine.get_frames_per_second()
|
||||
if(visible_mode == 1):
|
||||
if visible_mode == 1:
|
||||
$Label.text += "Move Speed: %.1f\n" % player.MOVE_SPEED if player else ""
|
||||
$Label.text += "Position: %.1v\n" % player.global_position if player else ""
|
||||
$Label.text += """
|
||||
@@ -37,12 +36,12 @@ func _unhandled_key_input(p_event: InputEvent) -> void:
|
||||
KEY_F8:
|
||||
get_tree().quit()
|
||||
KEY_F9:
|
||||
visible_mode = (visible_mode + 1 ) % 3
|
||||
visible_mode = (visible_mode + 1) % 3
|
||||
$Label/Panel.visible = (visible_mode == 1)
|
||||
visible = visible_mode > 0
|
||||
KEY_F10:
|
||||
var vp = get_viewport()
|
||||
vp.debug_draw = (vp.debug_draw + 1 ) % 6
|
||||
vp.debug_draw = (vp.debug_draw + 1) % 6
|
||||
get_viewport().set_input_as_handled()
|
||||
KEY_F11:
|
||||
toggle_fullscreen()
|
||||
@@ -56,8 +55,10 @@ func _unhandled_key_input(p_event: InputEvent) -> void:
|
||||
|
||||
|
||||
func toggle_fullscreen() -> void:
|
||||
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN or \
|
||||
DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
|
||||
if (
|
||||
DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
|
||||
or DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN
|
||||
):
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||
DisplayServer.window_set_size(Vector2(1280, 720))
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# The Steward — Action System Architecture
|
||||
|
||||
## Current flow
|
||||
|
||||
```text
|
||||
SimulationManager tick
|
||||
-> ActionExecutionSystem advances needs and working progress
|
||||
-> ActionSelectionSystem chooses an action when the NPC is idle
|
||||
-> SimulationManager stores the selected action
|
||||
-> npc_target_requested
|
||||
-> WorldViewManager supplies the active visual's current position
|
||||
-> ActionTargetResolver consumes simulation state + ActiveWorldAdapter facts
|
||||
-> SimulationManager stores/reserves the target
|
||||
-> npc_travel_requested
|
||||
-> WorldViewManager commands NpcVisual travel
|
||||
-> arrival/navigation failure returns to SimulationManager
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
||||
### ActionSelectionSystem
|
||||
|
||||
- reads NPC and village state;
|
||||
- evaluates current utility scores;
|
||||
- consumes the NPC's deterministic decision RNG;
|
||||
- returns an action ID, optional urgent-duration override, branch reason, and
|
||||
compared utility scores;
|
||||
- does not mutate targets, navigation, or visuals.
|
||||
|
||||
### ActionExecutionSystem
|
||||
|
||||
- advances hunger, energy, starvation, death, and working progress;
|
||||
- marks an action complete when its duration elapses;
|
||||
- does not select the next action or resolve a target.
|
||||
|
||||
### ActionTargetResolver
|
||||
|
||||
- reads target metadata from ActionDefinition;
|
||||
- consumes active-world facts through ActiveWorldAdapter;
|
||||
- checks simulation-owned ResourceStateRecord availability;
|
||||
- reserves and returns stable resource target IDs;
|
||||
- skips full-capacity activity sites based on authoritative NPC target claims;
|
||||
- returns typed activity-site, storage, or wander positions without owning
|
||||
presentation.
|
||||
|
||||
### ActiveWorldAdapter
|
||||
|
||||
- exposes loaded resource interaction positions;
|
||||
- exposes loaded storage and activity-site interaction positions;
|
||||
- exposes activity-site capacity as an active-world fact;
|
||||
- contains active-world query facts, not persistent mutable authority;
|
||||
- does not choose actions or reserve resources.
|
||||
|
||||
### WorldViewManager
|
||||
|
||||
- spawns and tracks NpcVisual instances;
|
||||
- supplies a visual's current position when resolution is requested;
|
||||
- applies travel commands;
|
||||
- reports arrival, navigation failure, and visual death state;
|
||||
- does not map actions, select targets, write target IDs, or mutate resources.
|
||||
|
||||
### SimulationManager
|
||||
|
||||
SimulationManager remains the orchestrator and event boundary. It owns
|
||||
simulation records, invokes the focused systems, stores selected actions and
|
||||
targets, and translates presentation callbacks into simulation transitions.
|
||||
It also publishes the latest `ActionSelectionResult` for presentation; the UI
|
||||
does not recompute decisions.
|
||||
Further decomposition should follow measured pressure rather than splitting it
|
||||
into managers for their own sake.
|
||||
|
||||
## Active-position contract
|
||||
|
||||
NpcVisual emits active position changes after successful movement.
|
||||
WorldViewManager forwards those facts through
|
||||
`SimulationManager.synchronize_npc_position()`. Arrival, navigation failure,
|
||||
and visual unload also perform a final synchronization.
|
||||
|
||||
SimNPC stores both authoritative position and the resolved travel destination.
|
||||
The destination is versioned simulation state, so reloading a visual resumes
|
||||
the same route request without rerolling wander, resolving another resource, or
|
||||
changing a reservation.
|
||||
|
||||
`tests/npc_visual_lifecycle_test.gd` proves that visual unload/reload preserves
|
||||
action, target ID, travel destination, resource reservation, position, and
|
||||
state checksum.
|
||||
|
||||
Unloaded NPCs do not yet resolve abstract travel time; they remain in their
|
||||
current task state until an active visual or a future simulation-LOD travel
|
||||
system advances them.
|
||||
@@ -0,0 +1,665 @@
|
||||
# The Steward — Build-in-Public Visual Slice Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This plan adapts the proposed “Jajce simulation garden” to the repository as it
|
||||
exists now. It does not assume a blank project.
|
||||
|
||||
The immediate visual goal is to replace the flat prototype presentation with a
|
||||
small, attractive, watchable environment while preserving the working NPC
|
||||
simulation, player controls, UI, task lifecycle, and navigation behavior.
|
||||
|
||||
This is a scoped visual-production plan. Milestone order and architecture gates
|
||||
are owned by [the learning roadmap](LEARNING_ROADMAP.md); simulation authority
|
||||
is governed by [ADR 0001](decisions/0001-simulation-authority-boundary.md).
|
||||
|
||||
Before runtime terrain integration, food and wood should migrate from abstract
|
||||
zones to actual finite world objects according to
|
||||
[the ResourceNode migration plan](RESOURCE_NODE_MIGRATION.md). This prevents the
|
||||
new environment from being authored around a zone model already marked for
|
||||
removal.
|
||||
|
||||
The slice should make a viewer understand this promise quickly:
|
||||
|
||||
> A cozy Bosnian-inspired world where autonomous people visibly make decisions,
|
||||
> work, struggle, and create stories.
|
||||
|
||||
This is not a plan to build the full city of Jajce. It is a plan to create one
|
||||
strong visual composition that can host the current simulation and grow with
|
||||
future systems.
|
||||
|
||||
## What already exists
|
||||
|
||||
Do not redo these tasks:
|
||||
|
||||
- Godot 4.7 project configuration;
|
||||
- Terrain3D 1.0.2 installed and enabled;
|
||||
- cross-platform Git configuration;
|
||||
- working `main.tscn`;
|
||||
- camera-relative player movement and elevated follow camera;
|
||||
- three autonomous simulated NPCs;
|
||||
- hunger, energy, starvation, death, task scoring, and task duration;
|
||||
- task-change and village-change signals;
|
||||
- visual NPC navigation and arrival reporting;
|
||||
- finite berry/tree/animal-camp ResourceNodes plus typed food, guard, study,
|
||||
and rest world sites;
|
||||
- aggregate village UI;
|
||||
- initial tree assets;
|
||||
- a baked navigation region for the current flat test area.
|
||||
|
||||
Terrain3D's bundled `demo/` directory and most of `addons/terrain_3d/` are
|
||||
plugin content. They are references, not the game.
|
||||
|
||||
## Current architectural constraint
|
||||
|
||||
`main.tscn` currently owns several different concerns:
|
||||
|
||||
```text
|
||||
Main
|
||||
├── JajceWorld
|
||||
├── Player
|
||||
├── CameraRig
|
||||
├── SimulationManager
|
||||
├── WorldViewManager
|
||||
├── ActiveNPCs
|
||||
├── typed world sites inside JajceWorld/WorldObjects
|
||||
├── WorldEnvironment
|
||||
└── UI
|
||||
```
|
||||
|
||||
The player still references interactable world sites through exported `NodePath`
|
||||
values. `NpcVisual` relies on the navigation map to reach typed `ActivitySite`,
|
||||
`StorageNode`, and `ResourceNode` interaction points.
|
||||
|
||||
Replacing the ground without respecting those references would break behavior
|
||||
that already works.
|
||||
|
||||
## Adjusted scene strategy
|
||||
|
||||
Create a reusable environment scene and instance it into the existing runtime.
|
||||
Keep game orchestration in `main.tscn`.
|
||||
|
||||
Target structure:
|
||||
|
||||
```text
|
||||
Main
|
||||
├── JajceWorld instanced environment
|
||||
│ ├── TerrainRoot
|
||||
│ │ └── Terrain3D
|
||||
│ ├── WaterRoot
|
||||
│ ├── VillageRoot
|
||||
│ ├── FoliageRoot
|
||||
│ ├── NavigationRegion3D
|
||||
│ ├── WorldObjects
|
||||
│ │ ├── ResourceNodes
|
||||
│ │ ├── BerryBush instances
|
||||
│ │ └── Tree instances
|
||||
│ │ ├── StorageSites
|
||||
│ │ │ └── VillagePantry
|
||||
│ │ └── ActivitySites
|
||||
│ │ ├── GuardPost
|
||||
│ │ ├── StudyDesk
|
||||
│ │ └── RestBench
|
||||
│ ├── DirectionalLight3D
|
||||
│ └── WorldEnvironment
|
||||
├── Player
|
||||
├── CameraRig
|
||||
├── SimulationManager
|
||||
├── WorldViewManager
|
||||
├── ActiveNPCs
|
||||
└── UI
|
||||
```
|
||||
|
||||
`JajceWorld` should contain geography and world-object presentations. The former
|
||||
legacy interaction locations now live as typed storage and activity sites under
|
||||
`WorldObjects`; `JajceWorld` should not own `SimulationManager`, simulated NPC
|
||||
data, player state, or UI.
|
||||
|
||||
Create a small look-development wrapper that instances the same world:
|
||||
|
||||
```text
|
||||
JajceLookdev
|
||||
├── JajceWorld
|
||||
└── BeautyCameraRig
|
||||
```
|
||||
|
||||
This provides a cinematic workbench without creating a second environment that
|
||||
later has to be rebuilt for gameplay.
|
||||
|
||||
## Adjusted file structure
|
||||
|
||||
Follow the repository's existing feature-oriented layout. Do not create six
|
||||
empty sub-scenes merely to match a theoretical architecture.
|
||||
|
||||
Start with:
|
||||
|
||||
```text
|
||||
world/
|
||||
└── jajce/
|
||||
├── JajceWorld.tscn
|
||||
├── JajceLookdev.tscn
|
||||
└── beauty_camera.gd
|
||||
|
||||
terrain/
|
||||
└── jajce/
|
||||
├── data/
|
||||
├── materials/
|
||||
├── textures/
|
||||
└── source/
|
||||
|
||||
assets/
|
||||
└── jajce/
|
||||
├── buildings/
|
||||
├── foliage/
|
||||
├── rocks/
|
||||
├── props/
|
||||
├── water/
|
||||
└── vfx/
|
||||
```
|
||||
|
||||
Extract `JajceWater`, `JajceVillageBlockout`, or other components into their own
|
||||
scenes only when they have meaningful internal behavior or are reused. Avoid
|
||||
scaffolding an elaborate directory tree before assets exist.
|
||||
|
||||
## Visual target
|
||||
|
||||
Build a compressed, game-readable Jajce-inspired valley:
|
||||
|
||||
| Inspiration | First-slice interpretation |
|
||||
| --- | --- |
|
||||
| Hilltop fortress | One strong blockout silhouette on the ridge |
|
||||
| Old town slope | Four to six house blocks following two terraces |
|
||||
| Pliva/Vrbas water | One main river and a suggested secondary channel |
|
||||
| Waterfall | Hero mesh/VFX feature visible from the main camera |
|
||||
| Watermills | One working mill silhouette, with room for later expansion |
|
||||
| Forested hills | Terrain3D foliage clusters and background tree masses |
|
||||
| Agricultural outskirts | Existing farm and forest jobs integrated into terrain |
|
||||
| Regional roads | One readable loop connecting world objects and legacy targets |
|
||||
|
||||
The fortress, waterfall, roofs, player, and at least two working NPCs should be
|
||||
readable in one strong composition.
|
||||
|
||||
## Scope
|
||||
|
||||
### First terrain size
|
||||
|
||||
Start around **512 m × 512 m**. The playable and polished area can be smaller
|
||||
inside it.
|
||||
|
||||
Do not begin at 1024 m simply because Terrain3D supports it. Expand only when
|
||||
the current camera, navigation routes, and composition need more space.
|
||||
|
||||
### First material set
|
||||
|
||||
Use at most six terrain surfaces:
|
||||
|
||||
1. soft grass;
|
||||
2. dirt path;
|
||||
3. warm cliff stone;
|
||||
4. wet riverbank stone;
|
||||
5. forest floor;
|
||||
6. compacted village ground.
|
||||
|
||||
The material set should establish large readable areas. Avoid high-frequency
|
||||
photorealistic noise.
|
||||
|
||||
### First architecture set
|
||||
|
||||
Use:
|
||||
|
||||
- one fortress silhouette;
|
||||
- four to six house blockouts;
|
||||
- one mill;
|
||||
- one bridge;
|
||||
- a few fences, carts, sacks, logs, and work props.
|
||||
|
||||
Do not begin with 12–20 finished houses. The first shot needs composition, not
|
||||
an asset catalog.
|
||||
|
||||
## Art direction rules
|
||||
|
||||
Translate the desired animated-film warmth into an original style:
|
||||
|
||||
| Element | Direction |
|
||||
| --- | --- |
|
||||
| Terrain | Broad painterly color regions and exaggerated silhouettes |
|
||||
| Grass | Soft masses and restrained sway, not dense visual noise |
|
||||
| Trees | Rounded canopies, varied scale, simple readable trunks |
|
||||
| Rocks | Chunky warm-gray forms with low-frequency detail |
|
||||
| Buildings | Light plaster/stone, warm roofs, dark timber accents |
|
||||
| Water | Turquoise/emerald body, strong foam shapes, soft reflection |
|
||||
| Lighting | Warm sun, cool shadows, mild atmospheric haze |
|
||||
| Characters | Simple proportions, clear profession colors and props |
|
||||
| Motion | Wind, water, smoke, leaves, cloth, birds, and purposeful NPC travel |
|
||||
|
||||
Cozy presentation does not remove scarcity, death, conflict, or ambition. It
|
||||
makes the world pleasant to inhabit and the people pleasant to watch.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
### Phase 0 — Preserve and capture the baseline 🔶 automated baseline complete
|
||||
|
||||
Before replacing anything:
|
||||
|
||||
- run the current prototype;
|
||||
- record a short baseline clip or screenshots;
|
||||
- record the development machine's frame time and obvious scene statistics;
|
||||
- verify all six task locations can be reached;
|
||||
- verify starvation, work completion, and death still function;
|
||||
- note current camera framing and movement feel.
|
||||
|
||||
This becomes both a regression reference and excellent before/after material.
|
||||
|
||||
### Exit condition
|
||||
|
||||
There is a reproducible baseline showing the existing flat prototype and its
|
||||
working behaviors.
|
||||
|
||||
`tests/jajce_runtime_integration_test.gd` and
|
||||
`docs/baselines/FLAT_MAP_BASELINE.md` establish the executable behavior
|
||||
baseline. A visual before-clip and frame-time capture remain useful content work
|
||||
but do not block the architecture gate.
|
||||
|
||||
### Systems prerequisite — Migrate food and wood targets ✅ complete
|
||||
|
||||
Implement and validate the first ResourceNodes on the current flat map before
|
||||
moving the playable runtime onto Terrain3D:
|
||||
|
||||
- berry bushes for `gather_food`;
|
||||
- trees for `gather_wood`;
|
||||
- stable target IDs;
|
||||
- nearest-available selection;
|
||||
- single-user reservation;
|
||||
- extraction after work completion;
|
||||
- depletion and replanning;
|
||||
- actual extracted amount applied to the village;
|
||||
- player interaction through the same extraction contract.
|
||||
|
||||
Keep the other task zones temporarily. Rest, eating, study, and patrol will
|
||||
eventually use activity, storage, or workstation targets rather than pretending
|
||||
to be resource sources.
|
||||
|
||||
### Exit condition
|
||||
|
||||
Food and wood work without their zones during normal play, resource conservation
|
||||
holds, and target failure does not leave a permanent reservation.
|
||||
|
||||
### Phase 1 — Create the reusable world scaffold ✅ complete
|
||||
|
||||
Create `world/jajce/JajceWorld.tscn` with:
|
||||
|
||||
- Terrain3D and dedicated terrain data;
|
||||
- placeholder `WaterRoot`;
|
||||
- placeholder `VillageRoot`;
|
||||
- placeholder `FoliageRoot`;
|
||||
- a navigation region;
|
||||
- resource-node, storage-site, and activity-site containers;
|
||||
- lighting and environment.
|
||||
|
||||
Create `JajceLookdev.tscn` that instances `JajceWorld` and adds a temporary
|
||||
beauty camera.
|
||||
|
||||
Do not migrate `main.tscn` yet. Do not duplicate simulation scripts inside the
|
||||
look-development scene.
|
||||
|
||||
### Exit condition
|
||||
|
||||
The same `JajceWorld` instance can open in the lookdev scene and can be instanced
|
||||
under a temporary runtime test without errors.
|
||||
|
||||
Implemented in `556d0fd` with dedicated Terrain3D seed data, reusable world and
|
||||
lookdev scenes, environment roots, stable-ID ResourceNodes, activity markers,
|
||||
and a headless scaffold test.
|
||||
|
||||
### Phase 2 — Greybox the hero composition ✅ initial proof complete
|
||||
|
||||
Sculpt by hand before attempting GIS/DEM import:
|
||||
|
||||
- one dominant ridge;
|
||||
- descending town terraces;
|
||||
- main river valley;
|
||||
- waterfall drop;
|
||||
- lower pool;
|
||||
- farm shelf;
|
||||
- forest work area;
|
||||
- a walkable road loop connecting world objects and remaining legacy locations.
|
||||
|
||||
Block out the fortress, houses, mill, bridge, and waterfall with primitives or
|
||||
simple meshes.
|
||||
|
||||
Place the beauty camera early. Terrain should be judged through the intended
|
||||
game and video cameras, not only from the editor's free camera.
|
||||
|
||||
### Navigation spike
|
||||
|
||||
Do an early rough navigation bake now. Do not wait until final art.
|
||||
|
||||
The purpose is to detect:
|
||||
|
||||
- slopes that are too steep;
|
||||
- terraces that cannot connect;
|
||||
- paths too narrow for agents;
|
||||
- waterfall or river barriers that split required routes;
|
||||
- terrain collision or navigation incompatibilities.
|
||||
|
||||
The final navigation bake still happens after terrain and props stabilize.
|
||||
|
||||
### Exit condition
|
||||
|
||||
One static frame contains the ridge landmark, roofs, river/waterfall, and
|
||||
village work area. A test agent can traverse the intended task loop.
|
||||
|
||||
The initial proof uses primitive ridge, fortress, terrace, house, mill, bridge,
|
||||
river, and waterfall silhouettes plus a temporary baked navigation loop. The
|
||||
Terrain3D seed remains flat; sculpt refinement and the final bake resume only
|
||||
after the architecture gate.
|
||||
|
||||
### Systems gate — Harden simulation authority ✅ complete
|
||||
|
||||
After the scaffold, greybox, and navigation spike prove the world shape, pause
|
||||
visual production and complete the mandatory architecture gate from the
|
||||
learning roadmap:
|
||||
|
||||
- deterministic clock and seeded randomness; ✅
|
||||
- fixed-seed headless scenarios and final-state checksum; ✅
|
||||
- versioned serializable NPC, village, resource, clock, and RNG records; ✅
|
||||
- resource amount and reservation authority outside scene nodes; ✅
|
||||
- stable action/profession IDs and initial definitions; ✅
|
||||
- separate action selection, execution, target resolution, and visual travel; ✅
|
||||
- explicit active-position synchronization. ✅
|
||||
|
||||
Do not solve this by moving simulation ownership into `JajceWorld`. The
|
||||
environment remains a presentation and active-world query surface.
|
||||
|
||||
### Exit condition
|
||||
|
||||
The same scenario produces the same result without `main.tscn`; unloading a
|
||||
visual does not change authoritative state; current resource and movement
|
||||
behavior still pass regression tests.
|
||||
|
||||
Completed with deterministic continuation, simulation-owned resources,
|
||||
definition-backed IDs, separated action responsibilities, active position
|
||||
synchronization, and visual unload/reload invariance tests.
|
||||
|
||||
### Phase 3 — Establish the beauty baseline
|
||||
|
||||
Add only the highest-value presentation:
|
||||
|
||||
- six terrain materials;
|
||||
- warm `WorldEnvironment`;
|
||||
- directional light and atmospheric haze;
|
||||
- simple river surface;
|
||||
- waterfall body, foam, and mist;
|
||||
- first foliage clusters using Terrain3D instancing where appropriate;
|
||||
- restrained grass/tree motion;
|
||||
- chimney smoke;
|
||||
- slow beauty-camera drift.
|
||||
|
||||
Avoid expensive water, dense individual grass nodes, and elaborate weather at
|
||||
this stage.
|
||||
|
||||
**Status: implementation complete.** The integrated world now has six
|
||||
Terrain3D layers, shaped terrain data, warm sky/fog/shadows, shader-driven
|
||||
river and waterfall surfaces, waterfall mist, authored building blockouts,
|
||||
wind-reactive foliage, chimney smoke, and beauty-camera drift. Automated
|
||||
coverage validates the texture contract, presentation nodes, terrain shape,
|
||||
stable resources, and navigation. Clip capture and a visual performance review
|
||||
remain presentation checkpoints rather than missing implementation.
|
||||
|
||||
### Exit condition
|
||||
|
||||
The lookdev scene can produce a compelling 10–20 second clip with no gameplay.
|
||||
The environment remains within the recorded prototype's agreed performance
|
||||
budget.
|
||||
|
||||
### Phase 4 — Integrate the existing game
|
||||
|
||||
Once the world composition is stable:
|
||||
|
||||
1. Instance `JajceWorld` into `main.tscn`.
|
||||
2. Disable or remove the old flat world only after the new instance is present.
|
||||
3. Place or move bushes and trees under
|
||||
`JajceWorld/WorldObjects/ResourceNodes` while preserving stable IDs.
|
||||
4. Reconnect remaining non-resource activities as typed sites under
|
||||
`JajceWorld/WorldObjects`.
|
||||
5. Bake navigation for the new walkable area.
|
||||
6. Test each existing task from multiple spawn positions.
|
||||
7. Verify task arrival still transitions from traveling to working.
|
||||
8. Verify task completion still changes village state.
|
||||
9. Verify depletion and reservations survive the scene migration.
|
||||
10. Verify player interaction ranges still make sense at the new scale.
|
||||
11. Verify dead NPC visuals do not block routes or retain reservations.
|
||||
12. Compare performance and behavior against the Phase 0 baseline.
|
||||
|
||||
Do not move simulation ownership into `JajceWorld` to make wiring convenient.
|
||||
|
||||
**Status: runtime integration complete.** `main.tscn` now instances the reusable
|
||||
world and points player/simulation adapters at its stable-ID resources, storage
|
||||
site, and activity sites. The duplicate flat world objects were removed.
|
||||
The runtime regression checks Terrain3D presence, 36 navigation routes, core
|
||||
task execution, starvation/death, player extraction parity, reservations, and
|
||||
NPC visual lifecycle. Terrain sculpting and presentation polish remain
|
||||
separate work, not blockers for this wiring milestone.
|
||||
|
||||
### Exit condition
|
||||
|
||||
The current six NPCs and player can perform all existing prototype behaviors
|
||||
inside the new terrain without regression.
|
||||
|
||||
### Phase 5 — Make the simulation readable on video
|
||||
|
||||
Add presentation for systems that already exist:
|
||||
|
||||
- simple stylized NPC placeholder instead of capsules;
|
||||
- profession color/prop distinctions;
|
||||
- compact task/need icon above selected or relevant NPCs;
|
||||
- name labels when useful;
|
||||
- an NPC reason inspector for development footage;
|
||||
- visible food, logs, guard, study, rest, and eating props;
|
||||
- cinematic/debug UI toggle;
|
||||
- a scenario reset and fixed seed for repeatable clips.
|
||||
|
||||
Do not fake activity that contradicts simulation state. A farmer icon should
|
||||
represent a real chosen task.
|
||||
|
||||
**Status: core readability complete.** Profession definitions now drive
|
||||
distinct NPC colors and props, world labels identify each villager, and the
|
||||
Tab-cycled inspector shows authoritative need values, task state, destination,
|
||||
decision branch, and utility scores. The existing carried-food prop and village
|
||||
panel expose immediate consequences. `F10` toggles the debug/cinematic
|
||||
presentation layer, while `F12` reloads the current scene as a repeatable
|
||||
simulation-garden demo reset.
|
||||
|
||||
### Exit condition
|
||||
|
||||
A viewer can distinguish at least three professions and understand one NPC's
|
||||
need, destination, and consequence without reading console output.
|
||||
|
||||
### Phase 6 — Produce the first public demo sequence
|
||||
|
||||
Capture a short sequence with a clear story:
|
||||
|
||||
1. establish the valley and waterfall;
|
||||
2. introduce one named NPC;
|
||||
3. show the NPC choosing and traveling to work;
|
||||
4. reveal the reason through a concise overlay;
|
||||
5. show the work changing a real village resource;
|
||||
6. end on either recovery or a visible shortage consequence.
|
||||
|
||||
The best first character is one of the existing named NPCs—Amina, Tarik, or
|
||||
Jasmin—rather than an unnamed cinematic extra.
|
||||
|
||||
### Exit condition
|
||||
|
||||
The clip has an understandable beginning, decision, and consequence. It does not
|
||||
depend on a voiceover to prove that the simulation is real.
|
||||
|
||||
## Build-in-public content pillars
|
||||
|
||||
Rotate between four types of updates.
|
||||
|
||||
### 1. Transformation
|
||||
|
||||
Show visible before/after progress:
|
||||
|
||||
- flat test ground to Jajce-inspired valley;
|
||||
- blockout waterfall to mist and foam;
|
||||
- capsules to readable professions;
|
||||
- empty paths to visible work traffic.
|
||||
|
||||
### 2. Tiny autonomous stories
|
||||
|
||||
Use one named NPC and one question:
|
||||
|
||||
- Why did Amina stop guarding?
|
||||
- Can Tarik gather food before starvation?
|
||||
- Why is Jasmin resting while the village is short on wood?
|
||||
- What happens when the food route is blocked?
|
||||
|
||||
### 3. System under the beauty
|
||||
|
||||
Show a beautiful shot, then reveal the debug reason trace, event, or resource
|
||||
flow that drives it.
|
||||
|
||||
### 4. Learning Godot publicly
|
||||
|
||||
Frame honest engineering lessons:
|
||||
|
||||
- Terrain3D sculpting and material mistakes;
|
||||
- navigation problems caused by terrain;
|
||||
- making NPC simulation deterministic;
|
||||
- profiling foliage versus agent cost;
|
||||
- separating simulation data from visual scenes.
|
||||
|
||||
## Short-form video template
|
||||
|
||||
For TikTok, Shorts, or brief social posts:
|
||||
|
||||
```text
|
||||
0–2 s Show the outcome or surprising NPC behavior
|
||||
2–5 s State one question
|
||||
5–15 s Show the cause unfolding
|
||||
15–25 s Reveal the systemic consequence
|
||||
End Show the next unresolved problem
|
||||
```
|
||||
|
||||
Use captions and strong visual causality. Avoid opening with a long editor tour.
|
||||
|
||||
## Initial video backlog
|
||||
|
||||
1. **“I replaced my programmer map with a Bosnian-inspired valley.”**
|
||||
2. **“This villager chose food over their profession—and here is why.”**
|
||||
3. **“The village can starve without a scripted quest.”**
|
||||
4. **“Every NPC physically walks to the work they selected.”**
|
||||
5. **“One blocked path broke the village economy.”**
|
||||
6. **“Making a Jajce-inspired waterfall in Godot Terrain3D.”**
|
||||
7. **“The same scene in cinematic mode and simulation-debug mode.”**
|
||||
8. **“Can three autonomous villagers survive this shortage?”**
|
||||
|
||||
Each video should correspond to real repository progress. Do not build isolated
|
||||
visual tricks solely for a post unless they also support the intended game.
|
||||
|
||||
## Performance guardrails
|
||||
|
||||
- Record frame time before adding terrain.
|
||||
- Treat Terrain3D instancing as the default for broad foliage where suitable.
|
||||
- Avoid physics and navigation obstacles on distant decorative vegetation.
|
||||
- Keep waterfall mist and smoke particle counts bounded.
|
||||
- Use simple water before adding screen-space reflection or refraction.
|
||||
- Test the gameplay camera, not only the beauty camera.
|
||||
- Profile editor and exported builds separately when export work begins.
|
||||
- Recheck navigation cost after every meaningful terrain expansion.
|
||||
- Do not increase NPC population while diagnosing environment cost.
|
||||
|
||||
## Source-control guardrails
|
||||
|
||||
- Commit Terrain3D data and game-authored terrain assets.
|
||||
- Continue ignoring `.godot/` and other imported caches.
|
||||
- Keep Godot `.import` and `.uid` sidecars tracked.
|
||||
- Do not modify or reformat the third-party Terrain3D addon incidentally.
|
||||
- Keep lookdev assets under game-owned paths, not inside the addon.
|
||||
- Make environment, integration, and simulation changes separate commits where
|
||||
practical.
|
||||
|
||||
## Definition of “Jajce Lookdev 01”
|
||||
|
||||
The adjusted first milestone is complete when:
|
||||
|
||||
- Terrain3D is used by `JajceWorld`; installation itself is already complete.
|
||||
- One approximately 512 m terrain exists.
|
||||
- Fortress ridge, town terraces, river valley, and waterfall drop are readable.
|
||||
- Six or fewer terrain materials establish the major surfaces.
|
||||
- One fortress, four to six houses, one mill, and one bridge are blocked out.
|
||||
- River, waterfall foam, mist, warm lighting, and first foliage are present.
|
||||
- A beauty camera produces a strong 10–20 second shot.
|
||||
- A rough navigation test proves the main route is viable.
|
||||
- The existing simulation has not yet been duplicated or moved.
|
||||
|
||||
## Definition of “Simulation Garden 01”
|
||||
|
||||
The follow-up milestone is complete when:
|
||||
|
||||
- `JajceWorld` is instanced in `main.tscn`;
|
||||
- scattered food and wood ResourceNodes work in the terrain;
|
||||
- remaining legacy task markers are reconnected;
|
||||
- player movement and all existing interactions work;
|
||||
- all three current NPCs navigate, work, eat, rest, starve, and die correctly;
|
||||
- one NPC's decision is readable through in-game presentation;
|
||||
- cinematic and debug views can show the same real scenario;
|
||||
- a repeatable public-demo clip shows an autonomous cause and consequence;
|
||||
- performance and navigation regressions are measured and documented.
|
||||
- the mandatory architecture gate passes before final beauty integration.
|
||||
|
||||
## Immediate implementation order
|
||||
|
||||
Completed:
|
||||
|
||||
1. Executable flat-map baseline.
|
||||
2. ResourceNode migration and player parity.
|
||||
3. `JajceWorld`, dedicated 512 m Terrain3D seed, and lookdev camera.
|
||||
4. Initial landmark greybox, stable-ID resources, activity markers, and
|
||||
navigation spike.
|
||||
5. Architecture gate, food transactions/events, and validated quicksave.
|
||||
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. 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
|
||||
exist.
|
||||
@@ -0,0 +1,53 @@
|
||||
# The Steward — Economic Event Stream
|
||||
|
||||
## Current contract
|
||||
|
||||
Every successful food movement creates one `EconomicEventRecord`:
|
||||
|
||||
```text
|
||||
resource_extracted
|
||||
storage_deposited
|
||||
storage_withdrawn
|
||||
item_consumed
|
||||
```
|
||||
|
||||
Each record contains a monotonically increasing event ID, event type,
|
||||
simulation tick, actor ID, source ID, destination ID, item ID, and transferred
|
||||
amount. Actor `-1` identifies a player-triggered extraction until persistent
|
||||
player identity is introduced.
|
||||
|
||||
Events are immutable facts about completed transfers. They do not perform the
|
||||
transaction and are not replayed to reconstruct current state. Resource,
|
||||
inventory, and storage records remain authoritative.
|
||||
|
||||
## Persistence and determinism
|
||||
|
||||
`SimulationStateRecord` schema v3 stores the ordered event stream and
|
||||
`next_event_id`. Schema v1 and v2 saves migrate to an empty stream beginning at
|
||||
ID zero. Parsing rejects duplicate event IDs and a next ID that could collide
|
||||
with restored history.
|
||||
|
||||
The food-loop regression verifies this chain:
|
||||
|
||||
```text
|
||||
bush -> NPC inventory -> village pantry -> NPC inventory -> consumed
|
||||
```
|
||||
|
||||
It checks event order, stable IDs, exact quantities, conservation, and
|
||||
save/restore continuity.
|
||||
|
||||
## Presentation
|
||||
|
||||
`npc_inventory_changed` is a transient presentation signal. The active
|
||||
`NpcVisual` shows a small food sack whenever its authoritative inventory
|
||||
contains food. A newly loaded visual derives the same state directly from the
|
||||
NPC record, so unloading presentation does not lose the fact.
|
||||
|
||||
## Deliberate limits
|
||||
|
||||
The stream is currently kept in full for the small simulation garden. Before
|
||||
large populations or long-running worlds, add measured retention,
|
||||
archival/summary rules, and query indexes. Denied attempts, depletion,
|
||||
witnesses, secrecy, causal links, and memories belong in later event/history
|
||||
slices; they should extend this record family without making prose
|
||||
authoritative.
|
||||
@@ -0,0 +1,64 @@
|
||||
# The Steward — Food Storage and Transactions
|
||||
|
||||
## First complete food loop
|
||||
|
||||
```text
|
||||
ResourceStateRecord (berry bush)
|
||||
-> gather_food extracts actual yield
|
||||
-> SimNPC inventory carries food
|
||||
-> deposit_food targets village_pantry
|
||||
-> StorageStateRecord receives food
|
||||
-> hungry NPC selects withdraw_food
|
||||
-> pantry transfers one food into NPC inventory
|
||||
-> eat consumes carried food and reduces hunger
|
||||
```
|
||||
|
||||
Food is conserved across source, carried inventory, and pantry. It leaves the
|
||||
world only when consumed.
|
||||
|
||||
## Authority
|
||||
|
||||
- `StorageStateRecord` owns pantry contents and capacity.
|
||||
- `SimNPC.inventory` owns carried item amounts.
|
||||
- `SimulationManager` performs deposit, withdrawal, and consumption
|
||||
transactions.
|
||||
- `village.food` is a synchronized aggregate view used by the existing UI,
|
||||
priorities, and utility scoring. It is not a second mutation path.
|
||||
- `StorageNode` supplies the active-world interaction position and presentation
|
||||
for deposit, withdrawal, eating, and current player pantry interaction.
|
||||
|
||||
The village pantry has stable ID `village_pantry`. Storage and NPC inventory
|
||||
are included in versioned state and deterministic checksums.
|
||||
|
||||
Successful extraction, deposit, withdrawal, and consumption also append
|
||||
structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md).
|
||||
Active NPCs display a small food sack while their authoritative inventory
|
||||
contains food; this is presentation derived from state, not a second inventory.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Transactions apply the amount actually available:
|
||||
|
||||
- extraction cannot exceed the source;
|
||||
- deposit cannot exceed storage capacity;
|
||||
- withdrawal cannot exceed pantry contents;
|
||||
- eating succeeds only when the NPC carries one food.
|
||||
|
||||
If another NPC empties the pantry first, withdrawal returns zero and the NPC
|
||||
replans on its next idle tick.
|
||||
|
||||
## Deliberate limits
|
||||
|
||||
This slice contains one authored pantry and one carried item type. It does not
|
||||
yet add:
|
||||
|
||||
- player inventory;
|
||||
- storage reservations or access capacity;
|
||||
- multiple households or ownership;
|
||||
- spoilage, item quality, weight, or stack definitions.
|
||||
|
||||
Economic transfer events exist, but event retention, denial/depletion facts,
|
||||
witnesses, and social interpretation are deliberately deferred.
|
||||
|
||||
Those should extend the transaction boundary rather than mutate counters
|
||||
directly.
|
||||
@@ -0,0 +1,733 @@
|
||||
# The Steward — Learning and Reusable-Systems Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This project is both a playable prototype and an advanced Godot curriculum.
|
||||
The goal is not merely to accumulate features. It is to understand, implement,
|
||||
measure, and document systems that can form the foundation of a later
|
||||
production game.
|
||||
|
||||
The roadmap prioritizes:
|
||||
|
||||
- learning through complete vertical behaviors;
|
||||
- separating simulation from presentation;
|
||||
- repeatable experiments and measurements;
|
||||
- systems that survive save/load and scale changes;
|
||||
- visible results suitable for building in public;
|
||||
- avoiding premature architecture that has not been tested by gameplay.
|
||||
|
||||
## How to use this roadmap
|
||||
|
||||
Each milestone should produce five outcomes:
|
||||
|
||||
1. **Concept learned** — the Godot or simulation topic being studied.
|
||||
2. **Playable proof** — behavior a player can observe or influence.
|
||||
3. **Reusable artifact** — a system, pattern, benchmark, or tool.
|
||||
4. **Exit test** — an objective condition for calling the milestone complete.
|
||||
5. **Retrospective** — what worked, what failed, and what should change.
|
||||
|
||||
Do not advance because files exist. Advance when the exit test passes.
|
||||
|
||||
The current implementation has completed ResourceNode migration through player
|
||||
parity plus the minimal Jajce scaffold and navigation proof. The architecture
|
||||
gate below is now active and must pass before beauty production or broader
|
||||
simulation features. See
|
||||
[ADR 0001](decisions/0001-simulation-authority-boundary.md).
|
||||
|
||||
## Mandatory architecture gate
|
||||
|
||||
This gate sits between the first Jajce terrain/navigation proof and substantial
|
||||
beauty work, inventories, schedules, relationships, or population growth.
|
||||
|
||||
It requires:
|
||||
|
||||
- an explicit deterministic simulation clock;
|
||||
- controlled, seedable random streams;
|
||||
- a headless fixed-seed scenario runner and final-state checksum;
|
||||
- versioned serializable records for current NPC, village, and resource state;
|
||||
- authoritative resource amounts and reservations outside scene nodes;
|
||||
- stable action/profession IDs and initial definitions;
|
||||
- separate action selection, execution, target resolution, and visual travel;
|
||||
- an active-world adapter for navigation facts and NPC position synchronization.
|
||||
|
||||
The gate is complete when the same scenario produces the same checksum without
|
||||
loading `main.tscn`, and loading or unloading a visual does not change
|
||||
authoritative NPC or resource state.
|
||||
|
||||
**Status: complete.** All architecture-gate slices are implemented:
|
||||
|
||||
- `SimulationClock` advances explicit fixed ticks while preserving remainder;
|
||||
- NPC decisions and visual wander requests use controlled per-NPC random
|
||||
streams derived from an exported simulation seed;
|
||||
- a headless scenario verifies same-seed equality, different-seed divergence,
|
||||
and a final-state checksum without loading `main.tscn`.
|
||||
- versioned JSON records capture and restore current NPC, village, resource,
|
||||
clock, and RNG state;
|
||||
- a save-at-tick-24 continuation test matches an uninterrupted 48-tick
|
||||
checksum and rejects unsupported schema versions.
|
||||
- `SimulationManager` owns resource records and all amount/reservation
|
||||
mutation; ResourceNode binds by stable ID as presentation and interaction
|
||||
geometry;
|
||||
- an unload/rebind regression proves resource state remains authoritative while
|
||||
its scene node is absent.
|
||||
- stable StringName IDs and validated custom resources define all current
|
||||
actions and professions;
|
||||
- NPC duration/preference logic, resource target metadata, generation, and
|
||||
serialized references resolve through the definition registry.
|
||||
- focused selection, execution, and target-resolution systems now own their
|
||||
respective rules;
|
||||
- ActiveWorldAdapter supplies loaded-world facts, while WorldViewManager only
|
||||
supplies visual position and executes emitted travel requests.
|
||||
- active movement synchronizes into authoritative NPC position state;
|
||||
- persisted travel destinations let visuals unload/reload without changing
|
||||
actions, targets, reservations, positions, RNG state, or checksums.
|
||||
|
||||
The fixed-seed scenario runs without `main.tscn`, and visual lifecycle
|
||||
invariance is covered headlessly. See
|
||||
[the action system architecture](ACTION_SYSTEM_ARCHITECTURE.md),
|
||||
[the simulation definitions](SIMULATION_DEFINITIONS.md) and
|
||||
[the simulation state schema](SIMULATION_STATE_SCHEMA.md).
|
||||
|
||||
## Three vertical slices
|
||||
|
||||
The long-term vision contains three major risks. Validate them separately before
|
||||
trying to combine the full game.
|
||||
|
||||
### A. Simulation slice
|
||||
|
||||
A small village runs autonomously, exposes understandable decisions, creates
|
||||
history, and produces interesting failures without scripted quests.
|
||||
|
||||
### B. Action slice
|
||||
|
||||
Movement, interaction, melee combat, companions, and small-group commands are
|
||||
fun in an isolated test environment.
|
||||
|
||||
### C. World slice
|
||||
|
||||
One village, one route, and part of a town exchange people, goods, information,
|
||||
and conflict while the player moves between simulation fidelity levels.
|
||||
|
||||
The simulation garden is the first slice. A combat sandbox should begin before
|
||||
the simulation is “finished,” because combat feel is a separate high-risk
|
||||
problem.
|
||||
|
||||
## Continuous learning practices
|
||||
|
||||
### Keep a decision record
|
||||
|
||||
For meaningful architectural choices, record:
|
||||
|
||||
- problem;
|
||||
- constraints;
|
||||
- options considered;
|
||||
- decision;
|
||||
- consequences;
|
||||
- conditions that would justify revisiting it.
|
||||
|
||||
This can later become a `docs/decisions/` collection. Do not create records for
|
||||
trivial edits.
|
||||
|
||||
### Maintain reproducible scenarios
|
||||
|
||||
Important behaviors should have fixed seeds and known initial state:
|
||||
|
||||
- normal village day;
|
||||
- food shortage;
|
||||
- blocked workplace;
|
||||
- exhausted worker;
|
||||
- path failure;
|
||||
- NPC death;
|
||||
- relationship conflict;
|
||||
- migration or faction pressure.
|
||||
|
||||
Scenarios make debugging, videos, balancing, and performance comparisons easier.
|
||||
|
||||
### Measure before optimizing
|
||||
|
||||
Maintain a small benchmark ledger:
|
||||
|
||||
| Measurement | Example |
|
||||
| --- | --- |
|
||||
| Population | 10, 100, 1,000, 10,000 data-only agents |
|
||||
| Simulated duration | one day, season, or year |
|
||||
| Wall-clock runtime | total and per simulated tick |
|
||||
| Update count | decisions and scheduled events processed |
|
||||
| Memory | state and history footprint |
|
||||
| Active visuals | navigation/animation cost |
|
||||
| Determinism | final-state checksum for a known seed |
|
||||
|
||||
### Pair systems with presentation
|
||||
|
||||
Every systems milestone should answer: “How can a viewer see this happening?”
|
||||
Every visual milestone should answer: “Which real state does this represent?”
|
||||
|
||||
## Milestone 0 — Baseline and observability
|
||||
|
||||
### Learn
|
||||
|
||||
- Godot project execution and scene-tree inspection;
|
||||
- debugger, profiler, monitors, and remote scene tree;
|
||||
- structured logging and debug overlays;
|
||||
- establishing behavioral and performance baselines.
|
||||
|
||||
### Build
|
||||
|
||||
- a simulation pause, single-step, speed control, and reset;
|
||||
- selectable NPC debug panel;
|
||||
- current need, task, task state, target, and task score display;
|
||||
- deterministic scenario seed entry;
|
||||
- concise event feed separate from verbose console logging;
|
||||
- baseline performance capture for the existing three-NPC prototype.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A simulation debug console/overlay and scenario runner.
|
||||
|
||||
### Exit test
|
||||
|
||||
From the running game, a developer can select any NPC and explain:
|
||||
|
||||
- what it is doing;
|
||||
- why it selected that task;
|
||||
- where it is going;
|
||||
- when the task should complete;
|
||||
- what state will change on completion.
|
||||
|
||||
The same seed and commands produce the same early simulation result.
|
||||
|
||||
## Milestone 1 — Simulation/presentation boundaries
|
||||
|
||||
### Learn
|
||||
|
||||
- `Node`, `RefCounted`, and `Resource` responsibilities;
|
||||
- signals versus direct calls;
|
||||
- dependency direction;
|
||||
- scene composition;
|
||||
- typed GDScript and stable identifiers.
|
||||
|
||||
### Build
|
||||
|
||||
- explicit simulation clock outside frame-dependent rules;
|
||||
- stable IDs for NPCs and locations;
|
||||
- serializable state records separated from immutable definitions;
|
||||
- an adapter interface between simulation and active visual NPCs;
|
||||
- smaller managers with focused responsibilities;
|
||||
- removal of direct task-string-to-marker coupling from the core.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A minimal headless-capable simulation kernel with a Godot presentation adapter.
|
||||
|
||||
### Exit test
|
||||
|
||||
The village can advance without instantiating `NpcVisual` scenes. Loading or
|
||||
unloading an NPC visual does not change the authoritative NPC state.
|
||||
|
||||
## Milestone 2 — Data-driven needs, professions, and actions
|
||||
|
||||
### Learn
|
||||
|
||||
- custom `Resource` definitions;
|
||||
- utility AI and consideration curves;
|
||||
- action preconditions, effects, duration, and interruption;
|
||||
- dependency injection through definitions;
|
||||
- debugging decision scores.
|
||||
|
||||
### Build
|
||||
|
||||
- need definitions;
|
||||
- profession definitions;
|
||||
- action definitions for eat, rest, gather food, gather wood, patrol, and study;
|
||||
- target discovery independent of hard-coded scene paths;
|
||||
- utility considerations with full reason traces;
|
||||
- reservations so multiple NPCs do not unrealistically claim one resource or
|
||||
workstation;
|
||||
- explicit task failure and replanning behavior.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A generic action-selection system that can evaluate defined actions against
|
||||
agent and world state.
|
||||
|
||||
### Exit test
|
||||
|
||||
A new profession and action can be added primarily through definitions and a
|
||||
small effect implementation. The inspector shows the winning score and rejected
|
||||
alternatives. NPCs recover from invalid or unavailable targets.
|
||||
|
||||
## Milestone 3 — One complete material economy
|
||||
|
||||
The staged replacement of abstract task zones begins with
|
||||
[the ResourceNode migration plan](RESOURCE_NODE_MIGRATION.md).
|
||||
|
||||
### Learn
|
||||
|
||||
- inventories and item stacks;
|
||||
- ownership, storage, reservations, and transactions;
|
||||
- production recipes;
|
||||
- world interaction points;
|
||||
- consistency and invariant testing;
|
||||
- structured economic events as authoritative facts.
|
||||
|
||||
### Build
|
||||
|
||||
Implement one honest food chain:
|
||||
|
||||
```text
|
||||
source -> gather/harvest -> carry -> store -> retrieve -> prepare/eat
|
||||
```
|
||||
|
||||
Include:
|
||||
|
||||
- item definitions;
|
||||
- personal and building inventories;
|
||||
- location-based storage;
|
||||
- physical carrying for active NPCs;
|
||||
- abstract transfer for distant agents;
|
||||
- reservation and cancellation;
|
||||
- spoilage only if it improves the initial loop;
|
||||
- player participation in the same resource rules;
|
||||
- structured gather, transfer, consume, deny, and depletion events carrying
|
||||
stable actor, target, location, and resource IDs.
|
||||
|
||||
Wood extraction already exists. Do not build a deeper wood production chain
|
||||
until the food chain is coherent.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
Inventory, reservation, transaction, and production primitives.
|
||||
|
||||
### Exit test
|
||||
|
||||
Every consumed unit of food can be traced to a source and a sequence of actions.
|
||||
No resource is created or destroyed except through an explicit, inspectable
|
||||
rule. A shortage is visible before the aggregate UI reports it.
|
||||
|
||||
## Milestone 4 — Attractive simulation garden
|
||||
|
||||
The detailed environment and public-demo sequence is maintained in
|
||||
[the build-in-public visual slice plan](BUILD_IN_PUBLIC_PLAN.md).
|
||||
|
||||
### Learn
|
||||
|
||||
- Terrain3D regions, sculpting, texturing, LOD, and instancing;
|
||||
- environment lighting, fog, sky, and post-processing;
|
||||
- modular scene construction;
|
||||
- navigation generation and validation;
|
||||
- water and vegetation presentation;
|
||||
- performance budgets for environment art.
|
||||
|
||||
### Build
|
||||
|
||||
- a compact Jajce-inspired valley composition;
|
||||
- ridge landmark, river/waterfall feature, bridge, mill area, farm, forest,
|
||||
homes, and workplaces;
|
||||
- warm painterly environment treatment;
|
||||
- replace capsules with simple stylized, animation-ready characters;
|
||||
- readable profession props/colors;
|
||||
- day/night presentation;
|
||||
- cinematic/debug display toggle;
|
||||
- stable navigation across all required work routes.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A terrain/world-building pipeline and environment performance checklist.
|
||||
|
||||
### Exit test
|
||||
|
||||
A short clip communicates the setting, player perspective, NPC professions, and
|
||||
one production loop without narration or console output. The environment stays
|
||||
inside the agreed frame-time and memory budget on the development machine.
|
||||
|
||||
## Milestone 5 — Time, schedules, and persistence
|
||||
|
||||
### Learn
|
||||
|
||||
- authoritative game time;
|
||||
- calendar and scheduled events;
|
||||
- serialization and schema versioning;
|
||||
- save migration;
|
||||
- restoring active scenes from data;
|
||||
- deterministic random streams.
|
||||
|
||||
### Build
|
||||
|
||||
- time-of-day and calendar;
|
||||
- work, meal, sleep, and discretionary schedule blocks;
|
||||
- simulation event queue;
|
||||
- versioned save format;
|
||||
- save/load for people, inventories, tasks, locations, and village state;
|
||||
- state validation after loading;
|
||||
- separate random streams where useful.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A versioned world-state persistence layer and simulation scheduler.
|
||||
|
||||
### Exit test
|
||||
|
||||
Save during travel, work, eating, and sleep; reload each save; then verify that
|
||||
the simulation resumes coherently. A deterministic headless scenario retains
|
||||
the same checksum across repeated runs.
|
||||
|
||||
## Milestone 6 — Events, memories, and relationships
|
||||
|
||||
### Learn
|
||||
|
||||
- event sourcing concepts without overcommitting to full event sourcing;
|
||||
- relationship graphs;
|
||||
- knowledge versus objective truth;
|
||||
- memory selection and decay;
|
||||
- social utility considerations;
|
||||
- tooling for history inspection.
|
||||
|
||||
### Build
|
||||
|
||||
- structured world event records;
|
||||
- actor, target, witness, location, cause, and consequence links;
|
||||
- relationship dimensions such as familiarity, affection, trust, fear,
|
||||
obligation, and hostility;
|
||||
- NPC knowledge of witnessed or communicated events;
|
||||
- memory importance and retention rules;
|
||||
- visible social reactions;
|
||||
- person and settlement history views.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
An event/history store and relationship system with query APIs.
|
||||
|
||||
### Exit test
|
||||
|
||||
Two NPCs exposed to different evidence can hold different beliefs about the
|
||||
same event. Their future choices visibly differ because of those memories and
|
||||
relationships.
|
||||
|
||||
## Milestone 7 — Rumours and emergent opportunities
|
||||
|
||||
### Learn
|
||||
|
||||
- information propagation;
|
||||
- query systems;
|
||||
- narrative framing from structured facts;
|
||||
- objective generation;
|
||||
- consequence-aware quest state;
|
||||
- natural-language presentation separated from authoritative state.
|
||||
|
||||
### Build
|
||||
|
||||
- rumour creation and transmission;
|
||||
- information accuracy, secrecy, and distortion rules;
|
||||
- detection of unresolved conditions;
|
||||
- interested-party and capable-helper queries;
|
||||
- opportunity/quest records that reference real entities and events;
|
||||
- multiple valid resolution paths;
|
||||
- consequence application to the source systems;
|
||||
- concise dialogue/event text templates.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
An emergent opportunity generator built over world-state queries.
|
||||
|
||||
### Exit test
|
||||
|
||||
A shortage, theft, injury, debt, or disappearance creates an opportunity only
|
||||
when someone knows and cares about it. Resolving it changes the originating
|
||||
people, resources, relationships, and history without spawning quest-only
|
||||
duplicates.
|
||||
|
||||
## Milestone 8 — Scalable spatial simulation and LOD
|
||||
|
||||
### Learn
|
||||
|
||||
- spatial partitioning;
|
||||
- relevance and interest management;
|
||||
- scheduled/batched updates;
|
||||
- data layout and allocation profiling;
|
||||
- active/abstract entity transitions;
|
||||
- statistical aggregation boundaries.
|
||||
|
||||
### Build
|
||||
|
||||
- spatial index for people, buildings, and events;
|
||||
- active, local-abstract, distant-individual, and settlement-aggregate modes;
|
||||
- promotion/demotion between modes;
|
||||
- abstract travel and work resolution;
|
||||
- background settlement updates;
|
||||
- performance harness for increasing population and history;
|
||||
- safeguards preserving named people and unresolved events.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A simulation-LOD framework with measured transition invariants.
|
||||
|
||||
### Exit test
|
||||
|
||||
The benchmark can simulate a target large population faster than real time while
|
||||
a smaller active population remains fully represented. Moving an NPC between
|
||||
fidelity levels preserves identity, inventory, task, relationships, and
|
||||
important history.
|
||||
|
||||
Set the target population only after collecting baseline measurements.
|
||||
|
||||
## Milestone 9 — Player interaction and social agency
|
||||
|
||||
### Learn
|
||||
|
||||
- interaction targeting;
|
||||
- context actions;
|
||||
- UI state machines;
|
||||
- dialogue presentation;
|
||||
- reputation and permission systems;
|
||||
- accessibility and feedback.
|
||||
|
||||
### Build
|
||||
|
||||
- inspect people, storage, workplaces, and events;
|
||||
- talk, trade, help, request, employ, threaten, or report where appropriate;
|
||||
- priority influence through plausible institutions rather than omniscient
|
||||
sliders;
|
||||
- reputation, authority, and social access;
|
||||
- player participation in professions;
|
||||
- feedback showing likely and actual consequences.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A context interaction framework connected to simulation actions and authority.
|
||||
|
||||
### Exit test
|
||||
|
||||
The player can understand and resolve the simulation garden's food crisis in
|
||||
multiple systemic ways without using a developer menu.
|
||||
|
||||
## Milestone 10 — Combat and companion sandbox
|
||||
|
||||
Begin this as a focused prototype before Milestones 6–9 are fully complete.
|
||||
Integrate it only after its fundamentals feel good.
|
||||
|
||||
### Learn
|
||||
|
||||
- animation state machines and blending;
|
||||
- hit detection and hurtboxes;
|
||||
- root motion versus code-driven motion;
|
||||
- input buffering;
|
||||
- camera feedback;
|
||||
- AI combat behavior;
|
||||
- group commands and formations;
|
||||
- combat profiling.
|
||||
|
||||
### Build
|
||||
|
||||
- isolated combat test scene;
|
||||
- responsive locomotion, attacks, defense, stagger, and recovery;
|
||||
- clear targeting without excessive lock-on dependence;
|
||||
- one companion with understandable autonomous behavior;
|
||||
- a small command vocabulary;
|
||||
- morale and retreat prototype;
|
||||
- simulation consequences for injury, death, witnesses, and reputation.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A combatant state model, animation interface, damage/event model, and companion
|
||||
command layer.
|
||||
|
||||
### Exit test
|
||||
|
||||
A short encounter is enjoyable when repeated without progression rewards. One
|
||||
companion can be directed without constant micromanagement, and combat outcomes
|
||||
produce valid world events.
|
||||
|
||||
## Milestone 11 — Multiple settlements, factions, and mobility
|
||||
|
||||
### Learn
|
||||
|
||||
- hierarchical simulation;
|
||||
- settlement economies;
|
||||
- faction goals and diplomacy;
|
||||
- trade routes and migration;
|
||||
- authority and governance;
|
||||
- large-group command abstraction.
|
||||
|
||||
### Build
|
||||
|
||||
- one village, one travel route, and one district or edge of the city;
|
||||
- movement of goods, people, rumours, and threats;
|
||||
- household and workplace membership;
|
||||
- faction membership and offices;
|
||||
- migration and recruitment;
|
||||
- small-scale leadership progression;
|
||||
- settlement and faction decision processes.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A regional simulation layer connecting local economies and social structures.
|
||||
|
||||
### Exit test
|
||||
|
||||
An event in one settlement creates measurable consequences elsewhere through
|
||||
real movement or communication. The player can gain responsibility through
|
||||
systemic relationships and actions rather than a fixed promotion quest.
|
||||
|
||||
## Milestone 12 — Integrated world slice
|
||||
|
||||
### Learn
|
||||
|
||||
- production hardening;
|
||||
- content pipelines;
|
||||
- onboarding complex systems;
|
||||
- balancing and telemetry;
|
||||
- regression testing;
|
||||
- profiling representative gameplay.
|
||||
|
||||
### Build
|
||||
|
||||
- the simulation, action, and world slices connected;
|
||||
- one coherent regional scenario;
|
||||
- polished onboarding for observation, work, social interaction, and combat;
|
||||
- save compatibility tests;
|
||||
- performance budgets and regression scenarios;
|
||||
- build-in-public demo mode;
|
||||
- documented extraction boundaries for reusable systems.
|
||||
|
||||
### Reusable artifact
|
||||
|
||||
A proven architecture and a set of tested subsystems ready to migrate or evolve
|
||||
into the production game.
|
||||
|
||||
### Exit test
|
||||
|
||||
The player can begin as an ordinary person, form relationships, participate in
|
||||
the economy, encounter an emergent problem, fight or negotiate through it, and
|
||||
leave persistent consequences in another settlement.
|
||||
|
||||
## Reuse checkpoints
|
||||
|
||||
At the end of each milestone, evaluate the system against this checklist:
|
||||
|
||||
- Does it run without the prototype's main scene?
|
||||
- Are definitions separate from mutable state?
|
||||
- Is state serializable and versioned where appropriate?
|
||||
- Can it be tested with a fixed seed?
|
||||
- Can presentation be replaced without rewriting its rules?
|
||||
- Does it expose reason/debug information?
|
||||
- Do measurements justify its complexity?
|
||||
- Has at least one real gameplay feature exercised the API?
|
||||
- Does persistent authority remain valid when its presentation node is absent?
|
||||
- Can an active visual synchronize position without becoming the sole owner of
|
||||
location state?
|
||||
|
||||
Do not extract a plugin solely because a system might be reusable. Prefer a
|
||||
clear internal module until multiple real consumers establish a stable API.
|
||||
|
||||
## Recommended implementation order from the current repository
|
||||
|
||||
Completed foundations:
|
||||
|
||||
- finite food and wood `ResourceNode` instances;
|
||||
- NPC target selection, reservation, depletion, and authoritative yield;
|
||||
- player parity through the same extraction contract;
|
||||
- navigation-failure recovery and an initial headless contention scenario;
|
||||
- executable flat-map baseline;
|
||||
- 512 m Jajce Terrain3D seed, greybox landmarks, stable-ID resource placement,
|
||||
lookdev scene, and tested temporary navigation loop.
|
||||
|
||||
The practical next sequence is:
|
||||
|
||||
Completed after the architecture gate:
|
||||
|
||||
- location-based pantry state, NPC-carried food, and explicit
|
||||
deposit/withdraw/consume transactions;
|
||||
- a headless conservation scenario covering the complete food loop;
|
||||
- deterministic structured economic events for extraction, deposit,
|
||||
withdrawal, and consumption;
|
||||
- persisted event identity/history and a visible carried-food prop derived
|
||||
from NPC inventory;
|
||||
- a validated local quicksave with bounded file size, guarded slot names,
|
||||
atomic replacement recovery, F5/F9 controls, and active-visual rebuilding.
|
||||
- an integrated Jajce beauty baseline with shaped Terrain3D data, a bounded
|
||||
six-layer palette, atmospheric lighting, shader water, foliage motion,
|
||||
building blockouts, and visible smoke/mist.
|
||||
- definition-driven profession colors/props and a selected-NPC inspector fed
|
||||
by real decision branches, destinations, needs, and utility scores.
|
||||
|
||||
The practical next sequence is:
|
||||
|
||||
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:
|
||||
|
||||
- `F10` cinematic/debug presentation toggle that hides development UI, world
|
||||
labels, and NPC name/profession labels without changing simulation state;
|
||||
- `F12` repeatable simulation-garden demo reset by reloading the current scene
|
||||
with the same authored seed and starting state.
|
||||
- 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.
|
||||
|
||||
## Subjects intentionally deferred
|
||||
|
||||
These are valuable future learning areas but should not distract from the first
|
||||
complete slices:
|
||||
|
||||
- multiplayer authority and replication;
|
||||
- procedural terrain generation;
|
||||
- machine-generated freeform dialogue;
|
||||
- full historical demography;
|
||||
- hundreds of item recipes;
|
||||
- siege-scale combat;
|
||||
- sophisticated market speculation;
|
||||
- native GDExtension optimization;
|
||||
- custom rendering technology.
|
||||
|
||||
Revisit them when a completed slice demonstrates a concrete need.
|
||||
@@ -0,0 +1,875 @@
|
||||
# The Steward — Project Context
|
||||
|
||||
> Agent-facing context for understanding the project quickly.
|
||||
>
|
||||
> Snapshot basis: repository state after commit `556d0fd`, July 2026. Treat the
|
||||
> code as the source of truth when this document and the implementation differ.
|
||||
|
||||
See the [documentation map](README.md) for the authority and scope of each plan.
|
||||
|
||||
## Quick orientation
|
||||
|
||||
**The Steward** is currently a small Godot prototype for an embodied village
|
||||
simulation. Its larger purpose is to become a learning laboratory for a
|
||||
performant, large-scale NPC simulation that can later power a systemic
|
||||
open-world RPG.
|
||||
|
||||
The current village is not intended to be the final game's scale. It is a
|
||||
controlled environment in which to learn and validate:
|
||||
|
||||
- NPC needs and decision-making;
|
||||
- professions, work, inventories, and economic dependencies;
|
||||
- actions, consequences, and world events;
|
||||
- relationships, memories, rumours, and persistent history;
|
||||
- emergent situations that can be surfaced as quests;
|
||||
- simulation level of detail for a much larger population;
|
||||
- readable world presentation and satisfying embodied play.
|
||||
|
||||
The desired game is not an omniscient city builder and not primarily a scripted
|
||||
quest RPG. The player exists physically inside the simulated society.
|
||||
|
||||
## North-star vision
|
||||
|
||||
> A systemic open-world RPG set around a city inspired by Jajce, Bosnia and
|
||||
> Herzegovina, and its surrounding villages. Every important NPC participates
|
||||
> in a simulated society, forms relationships, remembers events, and can affect
|
||||
> history. The player can remain an ordinary citizen, pursue a profession,
|
||||
> become a trader, criminal, warrior, or companion leader, command armies, and
|
||||
> eventually lead a faction.
|
||||
|
||||
The world should generate situations. Quests should primarily communicate,
|
||||
frame, and track those situations rather than manufacture disconnected content.
|
||||
|
||||
Example:
|
||||
|
||||
1. A harvest fails or a trade route becomes unsafe.
|
||||
2. Food scarcity raises pressure on households and professions.
|
||||
3. An NPC steals, migrates, changes work, joins a criminal group, or asks a
|
||||
relative for help.
|
||||
4. Witnesses form memories and spread partial information.
|
||||
5. Relationships, prices, safety, and faction attitudes change.
|
||||
6. The player learns about the situation through observation, conversation,
|
||||
rumours, employment, or authority.
|
||||
7. Any intervention changes the same world state that produced the problem.
|
||||
|
||||
The quest is therefore a view onto the simulation, not a parallel scripted
|
||||
reality.
|
||||
|
||||
## Product pillars
|
||||
|
||||
### 1. Simulated lives
|
||||
|
||||
NPCs should be people rather than production modifiers. Their needs,
|
||||
professions, possessions, schedules, abilities, ambitions, and circumstances
|
||||
drive their choices.
|
||||
|
||||
### 2. Persistent history
|
||||
|
||||
Important actions create structured events. Events can become memories,
|
||||
knowledge, rumours, relationships, reputations, grudges, obligations, and
|
||||
political consequences.
|
||||
|
||||
### 3. Emergent narrative
|
||||
|
||||
Authored content should provide rules, cultures, locations, characters,
|
||||
archetypes, and exceptional story beats. Ordinary quests and conflicts should
|
||||
preferably emerge from unresolved world conditions.
|
||||
|
||||
### 4. Embodied freedom
|
||||
|
||||
The player walks through the same spaces, uses the same resources, and
|
||||
participates in the same institutions as NPCs. They can play at different
|
||||
social scales without being forced toward rulership.
|
||||
|
||||
### 5. Legible autonomy
|
||||
|
||||
Complexity is valuable only when players can understand its consequences. The
|
||||
game should expose why an NPC chose an action and show resources moving through
|
||||
the world.
|
||||
|
||||
### 6. Small stories inside a large world
|
||||
|
||||
The eventual map may be large, but emotional attachment comes from recognizable
|
||||
people and local consequences. Simulation scale must not turn everyone into an
|
||||
anonymous number.
|
||||
|
||||
### 7. Enjoyable action
|
||||
|
||||
Movement and combat should be satisfying independently of the simulation.
|
||||
Companions and armies should offer expressive, enjoyable control rather than
|
||||
only statistical resolution.
|
||||
|
||||
### 8. A recognizable setting
|
||||
|
||||
The core region is inspired by Jajce: dramatic elevation, a fortified urban
|
||||
center, rivers, a waterfall, wooded slopes, lakes, watermills, roads, farms,
|
||||
and surrounding villages. This is an inspiration rather than a requirement for
|
||||
a literal historical reconstruction.
|
||||
|
||||
### 9. Cozy presentation with real stakes
|
||||
|
||||
The presentation should feel warm, painterly, pastoral, and inviting while the
|
||||
simulation remains capable of scarcity, conflict, death, political ambition,
|
||||
and social change.
|
||||
|
||||
## Build-in-public goals
|
||||
|
||||
Visual appeal and watchability are prototype requirements, not final polish.
|
||||
Each simulation feature should have a visible expression:
|
||||
|
||||
| Simulation state | Visible expression |
|
||||
| --- | --- |
|
||||
| Hunger | Looking for food, checking storage, eating, asking, stealing, or weakening |
|
||||
| Profession | Recognizable tools, clothing, workplace, route, and animation |
|
||||
| Task choice | Purposeful movement plus optional task/thought icon |
|
||||
| Production | Items visibly gathered, carried, stored, processed, and consumed |
|
||||
| Relationship | Greeting, helping, arguing, avoiding, mourning, or celebrating |
|
||||
| World event | Visible consequences and a concise event/history feed |
|
||||
| Settlement change | Construction, depletion, prosperity, damage, migration, or recovery |
|
||||
|
||||
Development clips should communicate cause and effect without requiring viewers
|
||||
to read console logs. A cinematic/debug toggle should eventually allow the same
|
||||
scenario to be shown both beautifully and analytically.
|
||||
|
||||
## Visual direction
|
||||
|
||||
The target is an original stylized look characterized by:
|
||||
|
||||
- painterly natural colors and soft, warm lighting;
|
||||
- readable, exaggerated terrain and building silhouettes;
|
||||
- lush vegetation with restrained environmental motion;
|
||||
- expressive, animation-friendly characters;
|
||||
- appealing water, mist, smoke, weather, and seasonal variation;
|
||||
- cozy settlements contrasted with a world capable of hardship;
|
||||
- a clear elevated third-person view that makes NPC activity readable.
|
||||
|
||||
Avoid generic high-fantasy spectacle and avoid treating visual style as a
|
||||
reason to hide the underlying simulation.
|
||||
|
||||
### Initial environment composition
|
||||
|
||||
The first public-facing environment should be a compressed simulation garden,
|
||||
not the complete region:
|
||||
|
||||
- a fortress or fortified landmark on an upper ridge;
|
||||
- a small settlement descending toward water;
|
||||
- a river, bridge, and waterfall or strong vertical water feature;
|
||||
- a mill channel and a small cluster of watermills;
|
||||
- farm terraces and a wooded work area;
|
||||
- a trade road and distant silhouettes that imply a larger world.
|
||||
|
||||
Terrain3D should own broad landforms, ground surfaces, distant landscape, and
|
||||
large-scale vegetation placement. Roads, river surfaces, waterfalls, cliffs
|
||||
that require overhangs, bridges, walls, buildings, and other authored landmarks
|
||||
should use dedicated meshes or appropriate spline/modular tooling.
|
||||
|
||||
## Core player loop
|
||||
|
||||
The intended loop is:
|
||||
|
||||
1. **Observe** people, places, stores, and local problems.
|
||||
2. **Understand** needs, causes, relationships, and competing priorities.
|
||||
3. **Influence** people through work, trade, conversation, policy, reputation,
|
||||
leadership, or force.
|
||||
4. **Act** personally through movement, interaction, production, exploration,
|
||||
and combat.
|
||||
5. **Witness consequences** in the same people and systems.
|
||||
6. **Adapt** to new opportunities and problems created by those consequences.
|
||||
|
||||
The current prototype implements a very early version of observe, autonomous
|
||||
task choice, physical travel, work completion, resource change, and direct
|
||||
player assistance. NPC `gather_food` and `gather_wood` tasks now use finite
|
||||
world `ResourceNode` instances (berry bushes and trees) rather than abstract
|
||||
zone markers. The obsolete farm, forest, and activity markers have been
|
||||
removed. Eating and food transfer target the typed village pantry, while rest,
|
||||
study, and patrol target typed activity sites. The migration is documented in
|
||||
[the ResourceNode plan](RESOURCE_NODE_MIGRATION.md).
|
||||
|
||||
## Current technology
|
||||
|
||||
- **Engine:** Godot 4.7 project configuration
|
||||
- **Renderer feature:** Forward Plus
|
||||
- **Language:** GDScript (`odig` analysis with warnings treated as errors)
|
||||
- **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 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`
|
||||
- **Commit convention:** Conventional Commits
|
||||
|
||||
Terrain3D includes binaries, source, editor tooling, examples, and a `demo/`
|
||||
directory. Most files under `addons/terrain_3d/` and `demo/` are third-party
|
||||
plugin content, not game architecture.
|
||||
|
||||
## Current playable state
|
||||
|
||||
### World and player
|
||||
|
||||
- `main.tscn` instances the reusable Jajce Terrain3D world.
|
||||
- The player is a `CharacterBody3D` represented by placeholder primitive
|
||||
geometry.
|
||||
- WASD movement is camera-relative.
|
||||
- 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.
|
||||
- `Escape` releases captured mouse input.
|
||||
|
||||
### Village simulation
|
||||
|
||||
The simulation currently creates six NPCs:
|
||||
|
||||
- Amina
|
||||
- Tarik
|
||||
- Jasmin
|
||||
- Elma
|
||||
- Mirza
|
||||
- Lejla
|
||||
|
||||
Each receives a random profession from:
|
||||
|
||||
- farmer;
|
||||
- woodcutter;
|
||||
- guard;
|
||||
- scholar;
|
||||
- wanderer.
|
||||
|
||||
Each simulated NPC currently stores:
|
||||
|
||||
- stable integer ID for the session;
|
||||
- name and profession;
|
||||
- hunger and energy;
|
||||
- strength and intelligence;
|
||||
- current task and task state;
|
||||
- task duration and progress;
|
||||
- simulated position;
|
||||
- starvation state and duration;
|
||||
- death state.
|
||||
|
||||
The village currently tracks:
|
||||
|
||||
- food, initially `20`;
|
||||
- wood, initially `10`;
|
||||
- safety, initially `50`;
|
||||
- knowledge, initially `0`;
|
||||
- per-resource modifiers;
|
||||
- per-resource priorities.
|
||||
|
||||
### NPC task lifecycle
|
||||
|
||||
Task states are:
|
||||
|
||||
```text
|
||||
idle -> traveling -> working -> complete -> idle
|
||||
|
|
||||
+-> navigation_failed -> wander -> idle
|
||||
```
|
||||
|
||||
NPCs currently choose among:
|
||||
|
||||
- gather food;
|
||||
- gather wood;
|
||||
- patrol;
|
||||
- study;
|
||||
- eat;
|
||||
- rest;
|
||||
- wander.
|
||||
|
||||
Needs override ordinary work. Otherwise a utility-like score combines:
|
||||
|
||||
- village priority;
|
||||
- critical and low resource thresholds;
|
||||
- profession preference;
|
||||
- a small random contribution.
|
||||
|
||||
The visual NPC walks toward the task's typed target position. Arrival tells the
|
||||
simulation to start work. Work progresses on simulation ticks. The village
|
||||
receives the result only when the task duration completes.
|
||||
|
||||
Starvation can reduce productivity and eventually kill an NPC. Dead visuals
|
||||
stop moving, change appearance, and no longer collide.
|
||||
|
||||
### Current world targets
|
||||
|
||||
The former farm, forest, food, guard, study, and rest markers have been deleted.
|
||||
The runtime now uses semantic world targets:
|
||||
|
||||
**Migration status:** NPC `gather_food` and `gather_wood` target `ResourceNode`
|
||||
instances (berry bushes and trees), with no marker fallback. Non-resource
|
||||
actions route through typed sites:
|
||||
|
||||
- patrol → `ActivitySite` (`guard_post`);
|
||||
- study → `ActivitySite` (`study_desk`);
|
||||
- rest → `ActivitySite` (`rest_bench`);
|
||||
- eat/deposit/withdraw → `StorageNode` (`village_pantry`).
|
||||
|
||||
The player also harvests food and wood directly from nearby `ResourceNode`
|
||||
instances. Extraction returns the actual amount removed, updates the village by
|
||||
that amount, and releases an NPC reservation if the source is depleted.
|
||||
Future resource expansion should add many finite `ResourceNode` instances rather
|
||||
than broad resource zones: trees in foliage clusters, berry patches, animal
|
||||
camps, and village stockpiles can be ranked by reachability, distance, safety,
|
||||
profession, and NPC comfort range. The current resolver already combines
|
||||
distance with resource `safety_risk`, `comfort_distance`, and
|
||||
`discovery_priority` metadata.
|
||||
|
||||
### Jajce scaffold
|
||||
|
||||
`world/jajce/JajceWorld.tscn` provides the reusable environment instanced by
|
||||
`main.tscn`:
|
||||
|
||||
- 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 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
|
||||
NavigationServer path queries; the scaffold and runtime tests capture that
|
||||
synchronization requirement.
|
||||
|
||||
### Current UI
|
||||
|
||||
A small village panel displays:
|
||||
|
||||
- food;
|
||||
- wood;
|
||||
- safety;
|
||||
- knowledge;
|
||||
- starving NPC count;
|
||||
- selected village modifiers.
|
||||
- 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, 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
|
||||
|
||||
### `simulation/SimNPC.gd`
|
||||
|
||||
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection,
|
||||
task progression, profession affinity, starvation, and death.
|
||||
|
||||
This separation from the visual node is an important architectural seed and
|
||||
should be preserved.
|
||||
|
||||
### `simulation/SimVillage.gd`
|
||||
|
||||
`SimVillage` is a `RefCounted` aggregate for shared resources, modifiers,
|
||||
priorities, and applying completed NPC work.
|
||||
|
||||
### `simulation/SimulationManager.gd`
|
||||
|
||||
`SimulationManager` is currently a scene-tree `Node` that:
|
||||
|
||||
- owns the village and NPC array;
|
||||
- advances a tick approximately every 1.2 seconds;
|
||||
- creates NPCs;
|
||||
- coordinates task completion;
|
||||
- emits village, task, and death signals;
|
||||
- exposes direct resource-changing methods to the player.
|
||||
|
||||
It currently combines clock, orchestration, event publication, population
|
||||
creation, and some gameplay API responsibilities.
|
||||
|
||||
### `world/world_view_manager.gd`
|
||||
|
||||
`WorldViewManager` bridges simulation data to visible NPC nodes. It:
|
||||
|
||||
- instantiates `NpcVisual` scenes;
|
||||
- resolves resource nodes, random wander targets, and remaining activity
|
||||
markers;
|
||||
- writes the selected ResourceNode ID onto the NPC as a transitional behavior;
|
||||
- sends targets to visuals;
|
||||
- reports arrival and navigation failure back to `SimulationManager`;
|
||||
- applies visual death state.
|
||||
|
||||
### `player/npc/NpcVisual.gd`
|
||||
|
||||
`NpcVisual` is the active-world representation of an NPC. It currently:
|
||||
|
||||
- owns a `NavigationAgent3D`;
|
||||
- obtains a navigation path;
|
||||
- moves and rotates toward path points;
|
||||
- reports arrival;
|
||||
- applies a simple death presentation.
|
||||
|
||||
### `player/player.gd` and `player/camera_rig.gd`
|
||||
|
||||
These implement camera-relative character movement, physical interaction
|
||||
proximity checks, ResourceNode harvesting, mouse capture, and the elevated
|
||||
follow camera.
|
||||
|
||||
### `world/ui/ui.gd`
|
||||
|
||||
The UI subscribes to village changes and formats aggregate village state.
|
||||
|
||||
## Current runtime flow
|
||||
|
||||
```text
|
||||
SimulationManager tick
|
||||
-> ActionExecutionSystem advances needs/work
|
||||
-> ActionSelectionSystem chooses an action for idle NPCs
|
||||
-> npc_target_requested
|
||||
-> WorldViewManager supplies the active visual position
|
||||
-> ActionTargetResolver consumes ActiveWorldAdapter facts
|
||||
-> SimulationManager stores/reserves the target
|
||||
-> npc_travel_requested
|
||||
-> WorldViewManager commands visual travel
|
||||
|
|
||||
v
|
||||
NpcVisual navigates through the active world
|
||||
|
|
||||
+-- arrived_at_target signal
|
||||
| |
|
||||
| v
|
||||
| SimulationManager marks NPC as working
|
||||
| |
|
||||
| v
|
||||
| Later ticks complete work
|
||||
| |
|
||||
| v
|
||||
| ResourceStateRecord.extract() -> village.apply_resource_delta()
|
||||
| |
|
||||
| v
|
||||
| village_changed signal updates the UI
|
||||
|
|
||||
+-- navigation_failed signal
|
||||
|
|
||||
v
|
||||
SimulationManager releases reservation, sets last_task, sends NPC to wander
|
||||
```
|
||||
|
||||
## Repository map
|
||||
|
||||
```text
|
||||
.
|
||||
├── addons/terrain_3d/ Third-party Terrain3D plugin
|
||||
├── assets/foliage/ Early tree assets
|
||||
├── demo/ Terrain3D's bundled demo, not the game
|
||||
├── docs/ Project context and plans
|
||||
├── player/
|
||||
│ ├── camera_rig.gd
|
||||
│ ├── player.gd
|
||||
│ └── npc/
|
||||
│ ├── NpcVisual.gd
|
||||
│ └── NpcVisual.tscn
|
||||
├── simulation/
|
||||
│ ├── SimNPC.gd
|
||||
│ ├── SimVillage.gd
|
||||
│ ├── SimulationClock.gd
|
||||
│ ├── SimulationManager.gd
|
||||
│ ├── actions/ Selection, execution, and target resolution
|
||||
│ ├── definitions/ Stable IDs and custom definition resources
|
||||
│ └── state/ Versioned simulation-state records
|
||||
├── tests/
|
||||
│ ├── action_system_boundaries_test.gd
|
||||
│ ├── deterministic_simulation_test.gd
|
||||
│ ├── food_storage_loop_test.gd
|
||||
│ ├── jajce_world_scaffold_test.gd
|
||||
│ ├── jajce_runtime_integration_test.gd
|
||||
│ ├── npc_visual_lifecycle_test.gd
|
||||
│ ├── resource_node_player_parity_test.gd
|
||||
│ ├── simulation_definitions_test.gd
|
||||
│ └── simulation_state_serialization_test.gd
|
||||
├── terrain/jajce/ Dedicated Terrain3D seed data and assets
|
||||
├── tools/
|
||||
│ └── generate_jajce_terrain_seed.gd
|
||||
├── world/
|
||||
│ ├── jajce/
|
||||
│ │ ├── JajceWorld.tscn
|
||||
│ │ ├── JajceLookdev.tscn
|
||||
│ │ ├── jajce_world.gd
|
||||
│ │ └── beauty_camera.gd
|
||||
│ ├── resource_nodes/
|
||||
│ │ ├── ResourceNode.gd
|
||||
│ │ ├── ResourceNode.gd.uid
|
||||
│ │ └── ResourceNode.tscn
|
||||
│ ├── active_world_adapter.gd
|
||||
│ ├── world_view_manager.gd
|
||||
│ └── ui/ui.gd
|
||||
├── main.tscn
|
||||
└── project.godot
|
||||
```
|
||||
|
||||
## Important limitations and technical debt
|
||||
|
||||
These are expected prototype constraints, not necessarily isolated bugs:
|
||||
|
||||
- Action and profession IDs are stable and definition-backed, but several
|
||||
action effects remain hard-coded.
|
||||
- Temporary activity markers have been removed; NPC and player food/wood
|
||||
gathering use `ResourceNode` instances with no fallback, food transfer uses
|
||||
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
|
||||
- Current NPC, village, resource, storage, event, clock, and RNG state serialize
|
||||
through world schema v3. F5/F9 provide one validated local quicksave; a save
|
||||
menu, metadata, and player-transform persistence remain deferred.
|
||||
- Simulation-owned resource records retain live amounts, reservations, and
|
||||
usage definitions while ResourceNode scenes are unloaded.
|
||||
- An explicit fixed-step clock converts frame delta into simulation ticks, but
|
||||
orchestration still lives on the scene-tree `SimulationManager`.
|
||||
- Automated coverage includes deterministic same-seed and save/restore
|
||||
continuation checks, player-parity/resource-contention, flat-map, and Jajce
|
||||
scaffold scenarios; broader gameplay coverage is still missing.
|
||||
- Resources are global floating-point counters rather than items in locations
|
||||
and inventories, except food, which now moves through sources, NPC inventory,
|
||||
and the village pantry.
|
||||
- NPCs do not have homes, schedules, possessions, memories, relationships,
|
||||
goals, or social knowledge.
|
||||
- The reason inspector exposes current decisions, but deeper historical traces
|
||||
and rejected preconditions are not yet retained.
|
||||
- Active navigation is used as if all agents are local; no simulation LOD exists.
|
||||
- Unloaded traveling NPCs preserve their state but do not yet advance through
|
||||
abstract travel time.
|
||||
- There is no spatial query/index layer for large populations.
|
||||
- SimulationManager still orchestrates multiple systems and player-facing
|
||||
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; `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
|
||||
presentation.
|
||||
|
||||
## Target simulation architecture
|
||||
|
||||
### Separate definitions, state, systems, and presentation
|
||||
|
||||
Use four conceptual layers:
|
||||
|
||||
1. **Definitions** — immutable data for needs, actions, professions, items,
|
||||
traits, event types, and locations.
|
||||
2. **State** — serializable runtime records for NPCs, inventories,
|
||||
relationships, settlements, factions, and history.
|
||||
3. **Systems** — clocks, scoring, action resolution, economy, relationships,
|
||||
events, rumours, and persistence.
|
||||
4. **Presentation adapters** — Godot nodes that render nearby state, accept
|
||||
player input, play animation, and provide UI.
|
||||
|
||||
Do not make the simulation core depend on `Node3D`, scene paths, animation,
|
||||
physics frames, or a currently loaded map.
|
||||
|
||||
The authoritative-boundary decision is recorded in
|
||||
[ADR 0001](decisions/0001-simulation-authority-boundary.md). The current
|
||||
`ResourceNode` state and `WorldViewManager` target selection are transitional,
|
||||
not patterns to extend into inventories, schedules, or relationships.
|
||||
|
||||
### Authority and active-world adapters
|
||||
|
||||
Persistent mutable state belongs in versioned, serializable simulation records.
|
||||
Systems mutate those records. Godot nodes:
|
||||
|
||||
- present nearby records;
|
||||
- expose active-world interaction transforms and navigation results;
|
||||
- translate player input into simulation commands;
|
||||
- synchronize active position through an explicit adapter;
|
||||
- never become the only owner of a persistent amount, reservation, action, or
|
||||
identity.
|
||||
|
||||
Target selection may consume facts supplied by the active world, but the
|
||||
presentation bridge must not author an NPC's decision or persistent target.
|
||||
|
||||
### Stable identity
|
||||
|
||||
Every persistent entity should eventually have a stable ID:
|
||||
|
||||
- person;
|
||||
- household;
|
||||
- item stack or significant item;
|
||||
- building;
|
||||
- workplace;
|
||||
- settlement;
|
||||
- faction;
|
||||
- location;
|
||||
- event.
|
||||
|
||||
References between simulation records should use IDs rather than live node
|
||||
references.
|
||||
|
||||
### Fixed and deterministic time
|
||||
|
||||
The simulation should advance with an explicit clock and controlled random
|
||||
source. Given the same starting state, seed, and player commands, a headless
|
||||
run should be reproducible.
|
||||
|
||||
This enables:
|
||||
|
||||
- debugging;
|
||||
- automated scenario tests;
|
||||
- balancing;
|
||||
- replaying build-in-public demonstrations;
|
||||
- comparing performance before and after optimization.
|
||||
|
||||
### Scheduled work rather than per-frame thinking
|
||||
|
||||
At scale, agents should wake for relevant decisions or scheduled updates rather
|
||||
than running full reasoning every frame. Examples include:
|
||||
|
||||
- need threshold crossed;
|
||||
- action completed or interrupted;
|
||||
- new information received;
|
||||
- path or workplace became unavailable;
|
||||
- daily schedule boundary;
|
||||
- relationship or faction event;
|
||||
- periodic low-frequency maintenance.
|
||||
|
||||
### Simulation fidelity levels
|
||||
|
||||
The eventual world needs multiple representations:
|
||||
|
||||
1. **Active:** full node, physics, navigation, perception, animation, and combat.
|
||||
2. **Local abstract:** individual position and scheduled actions without
|
||||
continuous physics.
|
||||
3. **Distant individual:** data-only people resolving travel and work by time.
|
||||
4. **Settlement aggregate:** carefully chosen economic or demographic
|
||||
aggregation for populations that do not currently need individual detail.
|
||||
|
||||
Moving between levels must preserve identity and important state. Aggregation
|
||||
must not erase named relationships or unresolved history.
|
||||
|
||||
### Action model
|
||||
|
||||
Actions should become data-driven definitions with:
|
||||
|
||||
- preconditions;
|
||||
- candidate targets;
|
||||
- utility considerations;
|
||||
- expected duration;
|
||||
- reservations;
|
||||
- resource costs;
|
||||
- effects;
|
||||
- interruption rules;
|
||||
- visible presentation hints;
|
||||
- reason tracing.
|
||||
|
||||
The first implementation can remain utility-based. Do not add a complex
|
||||
planner until current action scoring and sequencing demonstrate a concrete need.
|
||||
|
||||
### Events and history
|
||||
|
||||
Important outcomes should create structured facts rather than prose-only logs.
|
||||
A future event record might contain:
|
||||
|
||||
```text
|
||||
event_id
|
||||
event_type
|
||||
simulation_time
|
||||
location_id
|
||||
actor_ids
|
||||
target_ids
|
||||
witness_ids
|
||||
cause_event_ids
|
||||
resource_changes
|
||||
relationship_changes
|
||||
visibility/secrecy
|
||||
tags
|
||||
```
|
||||
|
||||
Memories, rumours, reputation, and quests should reference or transform these
|
||||
facts. Generated text is presentation; structured state remains authoritative.
|
||||
|
||||
### Emergent quest principle
|
||||
|
||||
A quest candidate should normally require:
|
||||
|
||||
- a real unresolved condition;
|
||||
- an NPC or institution that knows or cares about it;
|
||||
- a plausible way to communicate it;
|
||||
- one or more achievable interventions;
|
||||
- consequences that update the source simulation.
|
||||
|
||||
Avoid creating a duplicate quest-only bandit, item, victim, or relationship
|
||||
when an existing simulated entity can provide the situation.
|
||||
|
||||
### Performance principle
|
||||
|
||||
Design for scale, but optimize measured bottlenecks:
|
||||
|
||||
- keep headless benchmarks;
|
||||
- record agent count, simulated duration, tick cost, and allocations;
|
||||
- prefer event-driven/scheduled updates;
|
||||
- use spatial partitioning for local queries;
|
||||
- batch homogeneous work when profiling justifies it;
|
||||
- avoid premature native extensions or data-oriented rewrites before the
|
||||
behavior model stabilizes.
|
||||
|
||||
## Development strategy
|
||||
|
||||
The project has two interleaved tracks.
|
||||
|
||||
### Living simulation
|
||||
|
||||
- deterministic clock and state;
|
||||
- data-driven needs, professions, and actions;
|
||||
- inventories and economic flow;
|
||||
- events, history, relationships, and rumours;
|
||||
- simulation LOD and performance;
|
||||
- emergent opportunities and quests.
|
||||
|
||||
### Living presentation
|
||||
|
||||
- Jajce-inspired terrain composition;
|
||||
- attractive lighting, water, foliage, and weather;
|
||||
- readable characters and professions;
|
||||
- visible work, transport, and consequences;
|
||||
- interaction UI and simulation inspection;
|
||||
- combat feel, companions, and command feedback.
|
||||
|
||||
Every major systems milestone should produce a shareable visual behavior. Every
|
||||
visual milestone should support or reveal actual simulation state.
|
||||
|
||||
## Immediate milestone: scaffold, architecture gate, then simulation garden
|
||||
|
||||
Food and wood migration, the minimal `JajceWorld` proof, and the mandatory
|
||||
architecture gate are complete:
|
||||
|
||||
- explicit fixed-step simulation clock;
|
||||
- seeded per-NPC decision and wander random streams;
|
||||
- headless fixed-seed scenario with a final-state checksum;
|
||||
- versioned JSON records for NPC, village, resource, clock, and RNG state;
|
||||
- deterministic save/restore continuation with exact 64-bit RNG preservation;
|
||||
- simulation-owned resource amount/reservation state with ResourceNode
|
||||
presentation binding and unload/rebind coverage;
|
||||
- stable StringName action/profession IDs plus validated custom definition
|
||||
resources used by simulation, targeting, generation, and persistence;
|
||||
- separate selection, execution, target resolution, active-world query, and
|
||||
visual travel responsibilities;
|
||||
- authoritative active position and persisted travel destinations;
|
||||
- checksum-invariant NPC visual unload/reload with reservation preservation.
|
||||
|
||||
The location-based food loop is complete and traceable: gathering creates
|
||||
carried food, depositing fills the pantry, withdrawal retrieves one unit, and
|
||||
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, the bounded Jajce beauty pass, and the first
|
||||
simulation-garden runtime capture are complete.
|
||||
|
||||
The coherent visual slice can then aim for:
|
||||
|
||||
- one attractive valley section;
|
||||
- six named villagers;
|
||||
- three visibly distinct workplaces;
|
||||
- food and wood as location-based resources;
|
||||
- visible gathering, carrying, storing, and consuming;
|
||||
- hunger and energy;
|
||||
- one understandable shortage crisis;
|
||||
- direct player assistance and priority influence;
|
||||
- day/night presentation;
|
||||
- an in-game reason inspector;
|
||||
- deterministic replay or scenario reset;
|
||||
- save/load for the slice;
|
||||
- a stable 20–30 minute session.
|
||||
|
||||
Safety and knowledge can remain present, but their deeper production chains
|
||||
should follow a complete food loop rather than grow in parallel.
|
||||
|
||||
## Out of scope until the simulation garden works
|
||||
|
||||
- full regional map;
|
||||
- large city population;
|
||||
- multiplayer;
|
||||
- procedural world generation;
|
||||
- generations and inheritance;
|
||||
- complete market simulation;
|
||||
- kingdom-scale diplomacy;
|
||||
- large battles;
|
||||
- broad crafting catalog;
|
||||
- fully generated dialogue;
|
||||
- multiple large production chains;
|
||||
- production-quality character customization.
|
||||
|
||||
These remain part of the vision, not the next implementation target.
|
||||
|
||||
## Agent working guidelines
|
||||
|
||||
1. Read this document, [the learning roadmap](LEARNING_ROADMAP.md),
|
||||
[the ResourceNode migration](RESOURCE_NODE_MIGRATION.md), and
|
||||
[the build-in-public plan](BUILD_IN_PUBLIC_PLAN.md) before proposing a large
|
||||
architectural or world-production change.
|
||||
2. Inspect `git status` and preserve unrelated user changes.
|
||||
3. Treat `addons/terrain_3d/` and `demo/` as third-party code unless a task
|
||||
explicitly concerns the plugin.
|
||||
4. Preserve Godot `.uid` and asset `.import` sidecars. They are tracked project
|
||||
metadata; `.godot/` is the disposable cache.
|
||||
5. Keep simulation rules independent of visuals and loaded scenes.
|
||||
6. Prefer small vertical behavior slices over broad scaffolding.
|
||||
7. When adding a decision, add a way to inspect why it occurred.
|
||||
8. When adding persistent state, define how it saves and migrates.
|
||||
9. When optimizing, include a reproducible benchmark or measurement.
|
||||
10. Keep new systems data-driven only where it improves reuse or iteration;
|
||||
avoid abstraction without a demonstrated consumer.
|
||||
11. Use Conventional Commits.
|
||||
12. Update these documents when a decision materially changes the vision,
|
||||
architecture, milestones, or current-state description.
|
||||
13. Follow the authority order in [the documentation map](README.md); do not
|
||||
let a focused visual or migration plan silently redefine system ownership.
|
||||
|
||||
## Definition of “reusable for the future game”
|
||||
|
||||
A system is reusable when:
|
||||
|
||||
- it has no dependency on this prototype's scene paths or placeholder zones;
|
||||
- definitions can be supplied as data;
|
||||
- mutable state is serializable;
|
||||
- behavior can be exercised headlessly;
|
||||
- presentation communicates through an adapter or events;
|
||||
- performance characteristics are measured;
|
||||
- its public API is documented by real use, not speculative abstraction.
|
||||
|
||||
Reusable does not necessarily mean a separate Godot plugin. Extract a library
|
||||
only after the API has stabilized through use.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Active NPC:** A fully represented nearby character with a Godot scene node.
|
||||
- **Agent:** A simulated decision-making entity, usually an NPC.
|
||||
- **Event:** A structured fact that something happened in world time.
|
||||
- **History:** Persisted events and their enduring consequences.
|
||||
- **Memory:** An NPC's retained interpretation or knowledge of events.
|
||||
- **Presentation adapter:** Code that translates simulation state into scenes,
|
||||
animation, audio, UI, and player input.
|
||||
- **Quest:** A player-facing framing and tracking mechanism for an opportunity
|
||||
or unresolved condition.
|
||||
- **Reason trace:** Data explaining the inputs and scores behind a decision.
|
||||
- **Simulation garden:** A small, attractive, controlled world used to validate
|
||||
deep systems and make them watchable.
|
||||
- **Simulation LOD:** Changing computational fidelity based on relevance while
|
||||
preserving important identity and state.
|
||||
- **World event:** A consequential state transition that may be witnessed,
|
||||
remembered, communicated, and acted upon.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Documentation map
|
||||
|
||||
The project uses a small hierarchy so overlapping plans do not become competing
|
||||
sources of truth.
|
||||
|
||||
1. [`PROJECT_CONTEXT.md`](PROJECT_CONTEXT.md) is the canonical description of
|
||||
the vision, current implementation, target architecture, and active
|
||||
constraints.
|
||||
2. [`LEARNING_ROADMAP.md`](LEARNING_ROADMAP.md) owns milestone order,
|
||||
architecture gates, reusable-system exit tests, and intentionally deferred
|
||||
work.
|
||||
3. [`BUILD_IN_PUBLIC_PLAN.md`](BUILD_IN_PUBLIC_PLAN.md) owns the scoped Jajce
|
||||
visual slice. It must respect the architecture gates in the learning
|
||||
roadmap.
|
||||
4. [`RESOURCE_NODE_MIGRATION.md`](RESOURCE_NODE_MIGRATION.md) is a focused
|
||||
migration plan. Phases 1–6 are complete; follow-up work should expand
|
||||
resource discovery without reintroducing abstract resource zones.
|
||||
5. [`ACTION_SYSTEM_ARCHITECTURE.md`](ACTION_SYSTEM_ARCHITECTURE.md),
|
||||
[`ECONOMIC_EVENTS.md`](ECONOMIC_EVENTS.md),
|
||||
[`FOOD_STORAGE_ARCHITECTURE.md`](FOOD_STORAGE_ARCHITECTURE.md),
|
||||
[`SIMULATION_DEFINITIONS.md`](SIMULATION_DEFINITIONS.md), and
|
||||
[`SIMULATION_STATE_SCHEMA.md`](SIMULATION_STATE_SCHEMA.md) document the
|
||||
current data contracts.
|
||||
6. [`decisions/`](decisions/) contains durable architectural decisions,
|
||||
including consequences and revisit conditions.
|
||||
|
||||
When documents disagree:
|
||||
|
||||
- code and tests describe current behavior;
|
||||
- the newest accepted decision record governs architecture;
|
||||
- `PROJECT_CONTEXT.md` governs product intent;
|
||||
- `LEARNING_ROADMAP.md` governs what should be built next;
|
||||
- focused plans govern only their stated scope.
|
||||
|
||||
Update the smallest relevant set of documents after a decision or milestone.
|
||||
Do not copy full status tables into every plan.
|
||||
@@ -0,0 +1,624 @@
|
||||
# The Steward — ResourceNode Migration Plan
|
||||
|
||||
## Progress Overview
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| **1** | ResourceNode scene + registration | ✅ Complete |
|
||||
| **2** | Food target selection + reservation | ✅ Complete |
|
||||
| **3** | Authoritative extraction on completion | ✅ Complete |
|
||||
| **4a** | Wood target selection (NPC) | ✅ Complete |
|
||||
| **4b** | Player parity (extraction contract) | ✅ Complete |
|
||||
| **5** | Jajce world placement and runtime integration | ✅ Complete |
|
||||
| **6** | Replace remaining activity markers | ✅ Storage and activity sites authored |
|
||||
|
||||
### Key divergences from the original plan
|
||||
|
||||
- **Zone fallback removed** (Phase 3): `gather_food`/`gather_wood` no longer fall back to `FarmZone`/`ForestZone` markers when no resource node is available. The original plan called for a logged fallback; instead, the NPC goes idle (via `navigation_failed` → wander) to guarantee resource conservation. The obsolete farm and forest scene nodes have now also been deleted. The remaining markers are temporary targets for rest, study, and patrol—not resource fallbacks. Eating and food transfer now target the typed village pantry.
|
||||
- **`navigation_failed` signal** (discovered during Phase 4): Added a separate signal pathway to distinguish genuine arrival from navigation failure. The original plan treated all "arrivals" uniformly.
|
||||
- **Stale-path prevention** (discovered during Phase 4): Added `path_request_id` + `path_pending` to reject async path results that arrive after a new target was set or the NPC died.
|
||||
- **Duplicate node_id detection** (discovered during Phase 4): Added startup validation that disables nodes with duplicate IDs and pushes an error.
|
||||
- **Simulation-owned resource authority** (architecture gate): Resource amount,
|
||||
reservation, and enabled state now live in `ResourceStateRecord`; ResourceNode
|
||||
binds by stable ID and can unload without deleting authoritative state.
|
||||
|
||||
### Bugs found and fixed
|
||||
|
||||
| # | Bug | Phase found | Fix |
|
||||
|---|-----|-------------|-----|
|
||||
| 1 | Navigation failure: empty path caused NPC to idle permanently | Phase 3 stump | `_report_arrival_once()` on empty path (later replaced by `navigation_failed` signal) |
|
||||
| 2 | Unavailable-target deadlock: replan didn't trigger `npc_task_changed` | Phase 3 | Set `current_task = ""` so tick detects task change and emits signal |
|
||||
| 3 | Zone fallback broke conservation (fixed during Phase 4 removal) | Phase 3 | Removed zone fallback for gather tasks; NPC stays idle if no node available |
|
||||
| 4 | NPC collision blocking: NPCs physically blocked each other at shared destinations | Phase 3 | `collision_layer=2`, `collision_mask=1` on NpcVisual; `arrival_distance` 0.6→1.5 (later reverted to 0.6 after navigation_failed signal) |
|
||||
| 5 | Repetitive task selection: NPC chose same task every tick | Phase 3 | `last_task` tracking with -1.5 score penalty when same task repeats |
|
||||
| 6 | Wander didn't wander: wandered to rest marker instead of random offset | Phase 3 | Random ±6 unit offset from current position |
|
||||
| 7 | `:=` infers from Variant: warnings treated as errors from `odig` | Phase 2-3 | Use `=` instead of `:=` for calls returning `Variant` |
|
||||
| 8 | `Variant as StringName`: invalid cast | Phase 2-3 | Check for `null` on Dictionary `.get()` instead of casting |
|
||||
| 9 | Empty-path re-entrancy: immediate arrival report caused re-entrant signal chain | Phase 4 | Defer to `stuck_counter`/`stuck_time` in `_physics_process` instead |
|
||||
| 10 | Stale async path: `await physics_frame` result used after target or death changed | Phase 4 | `path_request_id`/`path_pending` guard |
|
||||
| 11 | Stolen extraction: NPC extracted after reservation was lost | Phase 4 | Guard extraction with the authoritative resource record's reservation |
|
||||
| 12 | Depleted target retained an NPC reservation after player harvesting | Phase 4 | Authoritative extraction now releases its reservation on depletion |
|
||||
|
||||
## Decision
|
||||
|
||||
The current task zones are temporary prototype scaffolding.
|
||||
|
||||
NPCs and the player should ultimately interact with actual world objects rather
|
||||
than travel to one abstract marker per task:
|
||||
|
||||
```text
|
||||
Current:
|
||||
gather_food -> FarmZone -> village food increases
|
||||
|
||||
First migration:
|
||||
gather_food -> choose BerryBush_01 -> travel to its interaction point
|
||||
-> extract berries -> bush amount decreases
|
||||
-> village food increases by the extracted amount
|
||||
```
|
||||
|
||||
This migration should happen on the current flat map before the Jajce
|
||||
environment is integrated. It is easier to debug target selection, reservation,
|
||||
depletion, and task completion without terrain and navigation changes happening
|
||||
at the same time.
|
||||
|
||||
The old resource zones and temporary activity markers have been removed. Food
|
||||
transfer targets `StorageNode`; rest, study, and patrol target `ActivitySite`.
|
||||
These are not resource fallbacks and are not resource-search areas.
|
||||
|
||||
## Why this is the next systems step
|
||||
|
||||
Resource nodes provide the first concrete bridge between:
|
||||
|
||||
- abstract simulation decisions;
|
||||
- spatial target selection;
|
||||
- active-world navigation;
|
||||
- finite world state;
|
||||
- visible cause and effect;
|
||||
- player and NPC use of the same objects;
|
||||
- future inventories, ownership, regeneration, and persistence.
|
||||
|
||||
They also make build-in-public footage more legible. A viewer can understand a
|
||||
villager walking to a specific berry bush and depleting it more easily than a
|
||||
villager entering an invisible “food zone.”
|
||||
|
||||
## Scope the first version correctly
|
||||
|
||||
The first `ResourceNode` supports only depletable material sources:
|
||||
|
||||
- berry bush or crop source -> food;
|
||||
- tree or wood pile -> wood.
|
||||
|
||||
Do not initially use `ResourceNode` for:
|
||||
|
||||
- beds or campfires;
|
||||
- guard posts;
|
||||
- study locations;
|
||||
- food storage;
|
||||
- homes;
|
||||
- generic workstations.
|
||||
|
||||
Those objects share target-selection behavior but not resource-extraction
|
||||
semantics.
|
||||
|
||||
The likely family of world targets is:
|
||||
|
||||
```text
|
||||
WorldActionTarget
|
||||
├── ResourceNode finite extraction: bush, tree, ore
|
||||
├── ActivitySite rest, patrol, study, socialize
|
||||
├── StorageNode deposit and withdraw items
|
||||
└── Workstation transform inputs into outputs
|
||||
```
|
||||
|
||||
Do not implement this complete inheritance tree now. Build `ResourceNode` from
|
||||
real requirements, then extract shared target behavior when a second target
|
||||
type proves what is actually common.
|
||||
|
||||
## Fit with the current code
|
||||
|
||||
Before this migration, the flow was:
|
||||
|
||||
```text
|
||||
SimNPC chooses a task string
|
||||
-> SimulationManager emits npc_task_changed
|
||||
-> WorldViewManager maps the task to one Marker3D
|
||||
-> NpcVisual travels to that marker
|
||||
-> arrival tells SimulationManager to start work
|
||||
-> task duration completes
|
||||
-> SimVillage applies a hard-coded task result
|
||||
```
|
||||
|
||||
The migrated flow is:
|
||||
|
||||
```text
|
||||
SimNPC chooses an action
|
||||
-> target resolver finds available matching ResourceNodes
|
||||
-> one target is reserved for the NPC
|
||||
-> NPC simulation stores the target's stable ID
|
||||
-> NpcVisual travels to the target interaction point
|
||||
-> arrival begins work
|
||||
-> completion extracts the actual available amount
|
||||
-> village receives exactly that amount
|
||||
-> target updates or becomes depleted
|
||||
-> reservation is released
|
||||
```
|
||||
|
||||
Current target behavior:
|
||||
|
||||
```text
|
||||
gather_food -> matching ResourceNode -> recover/wander when unavailable
|
||||
gather_wood -> matching ResourceNode -> recover/wander when unavailable
|
||||
other tasks -> existing zones
|
||||
```
|
||||
|
||||
Food and wood zone fallback was deliberately removed to preserve resource
|
||||
conservation.
|
||||
|
||||
## Repository-specific file layout
|
||||
|
||||
Keep scripts beside their feature scenes, matching the repository's existing
|
||||
feature-oriented structure:
|
||||
|
||||
```text
|
||||
world/
|
||||
└── resource_nodes/
|
||||
├── ResourceNode.gd
|
||||
├── ResourceNode.gd.uid
|
||||
└── ResourceNode.tscn
|
||||
```
|
||||
|
||||
Add instances under the current `main.tscn` while developing:
|
||||
|
||||
```text
|
||||
Main
|
||||
├── ResourceNodes
|
||||
│ ├── BerryBush_01
|
||||
│ ├── BerryBush_02
|
||||
│ ├── Tree_01
|
||||
│ └── Tree_02
|
||||
└── ActivityMarkers temporary eat/rest/study/patrol targets
|
||||
```
|
||||
|
||||
When `JajceWorld` is integrated, move or recreate the world-object instances
|
||||
under:
|
||||
|
||||
```text
|
||||
JajceWorld
|
||||
└── WorldObjects
|
||||
└── ResourceNodes
|
||||
```
|
||||
|
||||
Target discovery must not depend on that exact scene path. Resource nodes should
|
||||
register through a group or a small registry.
|
||||
|
||||
## First ResourceNode contract
|
||||
|
||||
The first node should answer:
|
||||
|
||||
- What is my stable world-object ID?
|
||||
- Which action can use me?
|
||||
- Which resource do I yield?
|
||||
- How much remains?
|
||||
- How much can one completed action extract?
|
||||
- Am I enabled and usable?
|
||||
- Who, if anyone, has reserved me?
|
||||
- Where should an active character stand?
|
||||
- Did extraction change or deplete me?
|
||||
|
||||
Recommended initial data:
|
||||
|
||||
```gdscript
|
||||
class_name ResourceNode
|
||||
extends Node3D
|
||||
|
||||
signal amount_changed(node_id: StringName, amount_remaining: float)
|
||||
signal depleted(node_id: StringName)
|
||||
signal reservation_changed(node_id: StringName, agent_id: int)
|
||||
|
||||
@export var node_id: StringName
|
||||
@export var action_id: StringName = &"gather_food"
|
||||
@export var resource_id: StringName = &"food"
|
||||
@export var initial_amount: float = 10.0
|
||||
@export var yield_per_action: float = 2.0
|
||||
@export var initial_enabled: bool = true
|
||||
@export var can_npcs_use: bool = true
|
||||
@export var can_player_use: bool = true
|
||||
@export var hide_visual_when_depleted: bool = false
|
||||
|
||||
@onready var interaction_point: Marker3D = $InteractionPoint
|
||||
var state: ResourceStateRecord
|
||||
```
|
||||
|
||||
Action IDs now use canonical `SimulationIds` values and resolve through
|
||||
validated `ActionDefinition` resources. ResourceNode's exported amount and
|
||||
enabled values are initialization defaults only; its bound
|
||||
`ResourceStateRecord` owns live mutable state.
|
||||
|
||||
### Avoid duplicated state
|
||||
|
||||
Do not store a separately mutable `is_depleted` flag in the first version.
|
||||
Derive it:
|
||||
|
||||
```gdscript
|
||||
func is_depleted() -> bool:
|
||||
return state.is_depleted() if state != null else initial_amount <= 0.0
|
||||
```
|
||||
|
||||
If regeneration or non-depleting sources later require more states, introduce
|
||||
them deliberately.
|
||||
|
||||
### Extraction result is authoritative
|
||||
|
||||
Extraction must return the amount actually removed:
|
||||
|
||||
```gdscript
|
||||
func extract(requested_amount: float = yield_per_action) -> float:
|
||||
if not can_extract():
|
||||
return 0.0
|
||||
|
||||
var extracted := minf(requested_amount, amount_remaining)
|
||||
amount_remaining -= extracted
|
||||
# Emit signals and update presentation.
|
||||
return extracted
|
||||
```
|
||||
|
||||
The village receives this returned amount. Do not let both `ResourceNode` and
|
||||
`SimVillage.apply_npc_task()` independently award the configured yield.
|
||||
|
||||
### Stable IDs
|
||||
|
||||
`node_id` must be:
|
||||
|
||||
- non-empty;
|
||||
- unique inside the world;
|
||||
- stable across save/load;
|
||||
- independent of display name and scene path.
|
||||
|
||||
For the first hand-authored nodes, explicit IDs such as
|
||||
`&"berry_bush_01"` are acceptable. Add startup validation for duplicates.
|
||||
|
||||
## Scene structure
|
||||
|
||||
The first reusable scene can remain small:
|
||||
|
||||
```text
|
||||
ResourceNode (Node3D)
|
||||
├── Visual (Node3D)
|
||||
├── InteractionPoint (Marker3D)
|
||||
└── DebugLabel (Label3D)
|
||||
```
|
||||
|
||||
Use `Node3D` for `Visual` rather than requiring `MeshInstance3D`; this permits a
|
||||
mesh, imported model, particles, or multiple visual children later.
|
||||
|
||||
The debug label should be controlled by a project/debug setting or inspector
|
||||
toggle rather than a compile-time constant. It should show:
|
||||
|
||||
- node ID;
|
||||
- resource;
|
||||
- remaining amount;
|
||||
- reservation owner;
|
||||
- depleted/disabled state.
|
||||
|
||||
## Reservation rules
|
||||
|
||||
Without reservations, several NPCs can choose one nearly depleted bush and all
|
||||
walk toward it.
|
||||
|
||||
The first version needs a single-user reservation:
|
||||
|
||||
```text
|
||||
available -> reserved by agent -> in use -> released
|
||||
```
|
||||
|
||||
A reservation must be released when:
|
||||
|
||||
- the task completes;
|
||||
- the NPC dies;
|
||||
- the target depletes;
|
||||
- travel fails;
|
||||
- the action is interrupted or replaced;
|
||||
- the target is disabled;
|
||||
- the NPC or visual is removed.
|
||||
|
||||
Reservation is not ownership. Ownership, permission, crime, and social
|
||||
consequences belong to later systems.
|
||||
|
||||
## Target selection
|
||||
|
||||
The first resolver may choose the nearest available matching node. It must still
|
||||
return a reason trace:
|
||||
|
||||
```text
|
||||
candidate BerryBush_01: accepted, distance 8.2
|
||||
candidate BerryBush_02: rejected, reserved by NPC 2
|
||||
candidate BerryBush_03: rejected, depleted
|
||||
selected BerryBush_01
|
||||
```
|
||||
|
||||
Future scoring can include:
|
||||
|
||||
- travel cost;
|
||||
- remaining yield;
|
||||
- danger;
|
||||
- tool requirements;
|
||||
- ownership and permission;
|
||||
- crowding;
|
||||
- profession;
|
||||
- expected completion time;
|
||||
- knowledge of the target.
|
||||
|
||||
Do not implement those before the nearest-available version works.
|
||||
|
||||
## Simulation-state boundary
|
||||
|
||||
The first `ResourceNode` is a Godot world node and may temporarily own its
|
||||
remaining amount. That is acceptable for the migration prototype, but it is not
|
||||
the final persistence boundary.
|
||||
|
||||
Preserve these rules now:
|
||||
|
||||
- `SimNPC` stores a target ID, not a `Node` reference;
|
||||
- simulation decisions do not store `NodePath`;
|
||||
- visual movement receives only a resolved position;
|
||||
- target extraction is coordinated through one adapter/registry;
|
||||
- node state has an obvious serialization representation.
|
||||
|
||||
Later, authoritative resource state can move into serializable simulation
|
||||
records while `ResourceNode` becomes its active-world presentation.
|
||||
|
||||
## Required changes by existing file
|
||||
|
||||
### `simulation/SimNPC.gd`
|
||||
|
||||
- add a selected target ID;
|
||||
- clear it when returning to idle or changing actions;
|
||||
- do not store `ResourceNode` references;
|
||||
- preserve current task lifecycle during the first migration.
|
||||
|
||||
### `world/world_view_manager.gd`
|
||||
|
||||
- query available resource nodes for `gather_food` and `gather_wood`;
|
||||
- reserve the selected target;
|
||||
- send its interaction position to `NpcVisual`;
|
||||
- use the old marker only when no valid node exists;
|
||||
- distinguish arrival at a specific target from arrival at a generic task zone.
|
||||
|
||||
Target discovery can begin here, but should move into a focused resolver or
|
||||
registry once selection has meaningful rules.
|
||||
|
||||
### `simulation/SimulationManager.gd`
|
||||
|
||||
- coordinate target completion and reservation release;
|
||||
- extract from the selected node once;
|
||||
- give `SimVillage` the actual result;
|
||||
- handle interruption, death, and failure cleanup;
|
||||
- prevent old hard-coded production from also firing.
|
||||
|
||||
### `simulation/SimVillage.gd`
|
||||
|
||||
- add an explicit resource-delta method;
|
||||
- separate generic resource application from task-name matching;
|
||||
- retain current action effects for non-migrated tasks.
|
||||
|
||||
### `player/player.gd`
|
||||
|
||||
- eventually resolve and use the same world targets as NPCs;
|
||||
- do not add a second player-only harvest path with different rules.
|
||||
|
||||
Player use can follow NPC migration rather than block its first test.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### ✅ Phase 1 — ResourceNode scene and registration *[complete]*
|
||||
|
||||
- create `ResourceNode.gd` and `ResourceNode.tscn`;
|
||||
- create a `ResourceNodes` parent in `main.tscn`;
|
||||
- place two bushes and two trees;
|
||||
- give every node a stable unique ID;
|
||||
- show debug state;
|
||||
- expose group/registry discovery.
|
||||
|
||||
**Exit:** nodes register, validate IDs, change amount, deplete, and update debug
|
||||
presentation without involving NPC logic.
|
||||
|
||||
### ✅ Phase 2 — Food target selection and reservation *[complete]*
|
||||
|
||||
- migrate only `gather_food`;
|
||||
- find nearest available bush;
|
||||
- reserve it;
|
||||
- store its ID on the NPC;
|
||||
- navigate to its interaction point;
|
||||
- release on interruption and death;
|
||||
- ~~retain `FarmZone` fallback with a visible warning~~ *(removed in Phase 4,
|
||||
see divergences above)*
|
||||
|
||||
**Exit:** two NPCs do not select the same single-user bush, and an NPC can
|
||||
replan when its chosen bush becomes unavailable.
|
||||
|
||||
### ✅ Phase 3 — Authoritative completion *[complete]*
|
||||
|
||||
- extract only after work duration completes;
|
||||
- return actual extracted amount;
|
||||
- apply that amount to village food;
|
||||
- prevent double production;
|
||||
- deplete and hide/alter the source as configured;
|
||||
- choose a new source for later tasks.
|
||||
|
||||
**Exit:** resource conservation holds:
|
||||
|
||||
```text
|
||||
total bush decrease == total village food increase
|
||||
```
|
||||
|
||||
for the initial gather-food scenario.
|
||||
|
||||
### ✅ Phase 4 — Wood and player parity *[complete]*
|
||||
|
||||
**Wood migration** ✅ complete:
|
||||
- `gather_wood` resolves loaded presentation candidates through
|
||||
`ActionTargetResolver` and `ActiveWorldAdapter`
|
||||
- Tree_01 (15 wood, yield 3.0) and Tree_02 (12 wood, yield 3.0) placed
|
||||
- Extraction follows same path as food (reserve → travel → work → extract → village resource delta)
|
||||
- Zone fallback removed; NPC goes idle if no tree available
|
||||
|
||||
**Player parity** ✅ complete:
|
||||
- Player interaction resolves the nearest player-usable resource node by its
|
||||
interaction point.
|
||||
- `SimulationManager.harvest_resource_node()` uses the same authoritative
|
||||
`ResourceStateRecord.extract()` contract and applies exactly the returned
|
||||
amount.
|
||||
- Player harvesting can contend with an NPC reservation; depletion releases
|
||||
that reservation so the NPC can recover cleanly.
|
||||
- Depleted and disabled targets provide explicit feedback.
|
||||
- Food and forest zone bindings were removed from the player.
|
||||
- `tests/resource_node_player_parity_test.gd` verifies food conservation, wood
|
||||
conservation, depletion, and player/NPC contention.
|
||||
|
||||
**Exit:** food and wood no longer require their abstract zones during normal
|
||||
play. NPC and player extraction obey the same availability, depletion, and
|
||||
authoritative-yield rules.
|
||||
|
||||
### Phase 5 — Jajce world placement
|
||||
|
||||
This phase is a bounded handoff to
|
||||
[the build-in-public visual plan](BUILD_IN_PUBLIC_PLAN.md), not permission for
|
||||
open-ended environment polish. Complete the scaffold, placement, and navigation
|
||||
proof, then pass the learning roadmap's architecture gate before the beauty
|
||||
baseline.
|
||||
|
||||
- place bushes and trees as real world objects in `JajceWorld`;
|
||||
- keep their IDs stable;
|
||||
- validate interaction points against terrain and navigation;
|
||||
- use the old task zones only for rest, eat, patrol, and study;
|
||||
- verify target discovery does not rely on parent scene paths.
|
||||
|
||||
**Status: complete.** `556d0fd` added the standalone scaffold and the runtime
|
||||
now instances it from `main.tscn`. Four stable-ID resources, the remaining
|
||||
activity markers, player bindings, active-world targeting, and all 24
|
||||
navigation routes run through `JajceWorld`; duplicate flat-map objects were
|
||||
removed.
|
||||
|
||||
**Exit:** the existing food and wood loops work after moving from the flat map
|
||||
to Terrain3D.
|
||||
|
||||
### Phase 6 — Replace the remaining activity-marker model
|
||||
|
||||
This is not part of the first ResourceNode implementation.
|
||||
|
||||
Introduce the next concrete target types:
|
||||
|
||||
- storage for eating and item transfer;
|
||||
- activity sites for rest, study, and guard work;
|
||||
- workstations when production recipes begin.
|
||||
|
||||
Remove each old zone only when its replacement has:
|
||||
|
||||
- target selection;
|
||||
- active interaction position;
|
||||
- reservation/capacity;
|
||||
- completion or ongoing-use behavior;
|
||||
- failure/interruption handling;
|
||||
- debug visibility;
|
||||
- save representation.
|
||||
|
||||
**Progress:** the `TaskZone` container, obsolete `FarmZone`/`ForestZone` nodes,
|
||||
and final `LegacyActivityMarkers` container have been deleted from `main.tscn`
|
||||
and `JajceWorld`. Task-to-marker mapping has been replaced by typed site
|
||||
queries.
|
||||
|
||||
The former `FoodZone`/`PantryMarker` is now an authored `StorageNode` at
|
||||
`JajceWorld/WorldObjects/StorageSites/VillagePantry`, backed by
|
||||
simulation-owned `StorageStateRecord` state and explicit deposit/withdraw/eat
|
||||
transactions. Successful food transfers emit serializable economic events, and
|
||||
an active NPC visibly carries food between source, pantry, and consumption.
|
||||
|
||||
Guard, study, and rest are now authored `ActivitySite` instances at
|
||||
`JajceWorld/WorldObjects/ActivitySites`. The adapter exposes loaded activity
|
||||
site candidates with capacity metadata, and `ActionTargetResolver` skips sites
|
||||
whose current NPC target claims already meet that capacity. These claims are
|
||||
derived from authoritative NPC target state rather than a presentation-only
|
||||
counter.
|
||||
|
||||
**Exit:** temporary activity markers and task-to-marker mapping are deleted,
|
||||
not merely unused.
|
||||
|
||||
**Status: complete for the marker-removal migration.** Follow-up work now
|
||||
extends target scoring rather than reintroducing zones: resources can be spread
|
||||
as many finite `ResourceNode` instances across foliage, animal camps, berry
|
||||
patches, and village stockpiles, then ranked by distance, reachability, safety,
|
||||
profession, and NPC comfort range. The first scoring pass stores
|
||||
`safety_risk`, `comfort_distance`, and `discovery_priority` on resource state
|
||||
and uses them when selecting NPC resource targets.
|
||||
|
||||
The first validated spread contains twelve finite resources: berry bushes and
|
||||
patches, animal camps, trees, and one village woodpile. Placement is guarded by
|
||||
headless tests for stable IDs, food/wood coverage, semantic contexts,
|
||||
discovery metadata, navigation reachability, and non-overlapping pantry/player
|
||||
interaction range.
|
||||
|
||||
## Test scenarios
|
||||
|
||||
At minimum, exercise:
|
||||
|
||||
1. one NPC and one bush;
|
||||
2. two NPCs and one bush;
|
||||
3. one NPC and two bushes at different distances;
|
||||
4. selected bush depletes before arrival;
|
||||
5. selected bush depletes exactly on completion;
|
||||
6. NPC dies while traveling;
|
||||
7. NPC dies while working;
|
||||
8. navigation fails;
|
||||
9. node is disabled while reserved;
|
||||
10. no matching node causes recovery without zone production; ✅ implemented
|
||||
11. player and NPC contend for the same source; ✅ automated
|
||||
12. duplicate node IDs are detected.
|
||||
|
||||
Use a fixed seed and concise reason tracing where possible.
|
||||
|
||||
## Video opportunities
|
||||
|
||||
The migration naturally creates strong short-form demonstrations:
|
||||
|
||||
- “My NPCs stopped walking to invisible work zones.”
|
||||
- “Two hungry villagers want the same berry bush.”
|
||||
- “This bush contains the food—the UI counter no longer creates it.”
|
||||
- “What happens when the closest resource is already reserved?”
|
||||
- “A depleted forest changes what every woodcutter decides.”
|
||||
|
||||
Show the beautiful result first, then reveal target selection, reservation, and
|
||||
resource conservation through debug presentation.
|
||||
|
||||
## Implementation status
|
||||
|
||||
| # | Task | Status | Commits |
|
||||
|---|------|--------|---------|
|
||||
| 1 | Create `ResourceNode.gd` and `ResourceNode.tscn` | ✅ Done | `9cd38a1` |
|
||||
| 2 | Add `Main/ResourceNodes` | ✅ Done | `9cd38a1` |
|
||||
| 3 | Place BerryBush_01, BerryBush_02, Tree_01, Tree_02 | ✅ Done | `9cd38a1` |
|
||||
| 4 | Add stable IDs and registration validation | ✅ Done | `9cd38a1`, `1837bc0` |
|
||||
| 5 | Verify extraction and depletion manually | ✅ Done | `9cd38a1` |
|
||||
| 6 | Add target ID to `SimNPC` | ✅ Done | `9cd38a1` |
|
||||
| 7 | Migrate `gather_food` target selection | ✅ Done | `9cd38a1`, `c6033b6` |
|
||||
| 8 | Add reservation and cleanup | ✅ Done | `9cd38a1`, `b8752f8` |
|
||||
| 9 | Apply actual extracted yield on completion | ✅ Done | `c6033b6` |
|
||||
| 10 | Add navigation failure handling (replaces regression scenarios) | ✅ Done | `b8752f8` |
|
||||
| 11 | Migrate wood | ✅ Done | `c6033b6`, `8049c52` |
|
||||
| 12 | Give the player the same extraction path | ✅ Done | `326df51` |
|
||||
| 13 | Integrate the Jajce world scaffold into runtime | ✅ Done | `556d0fd`, current phase |
|
||||
|
||||
## Definition of done for the first migration
|
||||
|
||||
| Criterion | Status |
|
||||
|-----------|--------|
|
||||
| Food and wood use actual finite world objects | ✅ |
|
||||
| NPCs select available targets and reserve them | ✅ |
|
||||
| NPC state stores stable target IDs rather than nodes or scene paths | ✅ |
|
||||
| Active visuals navigate to explicit interaction points | ✅ |
|
||||
| Work duration completes before extraction | ✅ |
|
||||
| Village resources increase only by the amount actually extracted | ✅ |
|
||||
| Depletion changes availability and presentation | ✅ |
|
||||
| Failure, interruption, and death release reservations | ✅ |
|
||||
| Old food and forest zone fallbacks are no longer used by NPCs | ✅ (fallback removed by design) |
|
||||
| The player and NPCs can use the same extraction contract | ✅ |
|
||||
| Behavior is inspectable and covered by reproducible scenarios | ✅ (debug logging, node overlay, headless player-parity test) |
|
||||
@@ -0,0 +1,75 @@
|
||||
# The Steward — Simulation Definitions
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable IDs and immutable definitions now form the vocabulary shared by
|
||||
simulation state, systems, world adapters, scenes, tests, and future save
|
||||
migrations.
|
||||
|
||||
`SimulationIds` is the canonical source for code-facing `StringName` IDs.
|
||||
`ActionDefinition` and `ProfessionDefinition` are editor-readable custom
|
||||
resources. `SimulationDefinitions` loads, resolves, and validates the complete
|
||||
prototype set.
|
||||
|
||||
## Current action contract
|
||||
|
||||
Each executable action definition contains:
|
||||
|
||||
- stable `action_id`;
|
||||
- display name;
|
||||
- default duration;
|
||||
- optional preferred profession ID;
|
||||
- target type: resource, activity, or free movement;
|
||||
- resource action ID when the action targets a ResourceNode.
|
||||
|
||||
The current actions are gather food, gather wood, deposit food, withdraw food,
|
||||
patrol, study, eat, rest, and wander. Idle and dead are stable state sentinels,
|
||||
not executable action definitions.
|
||||
|
||||
SimNPC reads default duration and preferred-profession metadata from these
|
||||
definitions. WorldViewManager reads target type and resource-action metadata
|
||||
instead of maintaining a separate gather-action map.
|
||||
|
||||
## Current profession contract
|
||||
|
||||
Each profession definition contains a stable `profession_id`, display name,
|
||||
body color, and prop color. The current professions are farmer, woodcutter,
|
||||
guard, scholar, and wanderer.
|
||||
|
||||
NPC generation chooses from the registry's stable IDs. NPC state stores and
|
||||
restores those IDs as `StringName`, and rejects records that reference an
|
||||
unknown profession or executable action.
|
||||
|
||||
## Validation
|
||||
|
||||
The registry rejects:
|
||||
|
||||
- missing or duplicate IDs;
|
||||
- empty display names;
|
||||
- non-positive action durations;
|
||||
- invalid target types;
|
||||
- resource actions without a resource-action ID;
|
||||
- references to unknown professions or resource actions;
|
||||
- definition resources that fail to load.
|
||||
|
||||
`tests/simulation_definitions_test.gd` verifies registry integrity,
|
||||
definition-backed behavior, unknown-ID rejection, and serialization
|
||||
round-tripping.
|
||||
|
||||
## Deliberate boundary
|
||||
|
||||
Definitions currently own identity and the static metadata already proven by
|
||||
the prototype. They do not yet own:
|
||||
|
||||
- utility thresholds and consideration curves;
|
||||
- preconditions or costs;
|
||||
- completion effects;
|
||||
- interruption and failure policy;
|
||||
- reservation strategy;
|
||||
- detailed target-selection policy beyond current resource/activity/free
|
||||
metadata;
|
||||
- richer presentation hints beyond the current profession palette.
|
||||
|
||||
Selection, execution, target resolution, and visual travel are now separate
|
||||
responsibilities. The remaining concerns should move into definitions only
|
||||
when the action-system implementations prove a stable, reusable contract.
|
||||
@@ -0,0 +1,113 @@
|
||||
# The Steward — Simulation State Schema
|
||||
|
||||
## Current contract
|
||||
|
||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||
simulation. The current world schema is v3 and captures:
|
||||
|
||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||
clock ticks;
|
||||
- every NPC's identity, needs, attributes, task lifecycle, target, position,
|
||||
starvation state, and decision RNG stream;
|
||||
- village resource counters;
|
||||
- controlled per-NPC wander RNG streams;
|
||||
- scene-independent resource state plus the definition facts needed while its
|
||||
ResourceNode is unloaded.
|
||||
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
||||
and its next stable event ID.
|
||||
|
||||
The top-level identity is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "the_steward.simulation",
|
||||
"schema_version": 3
|
||||
}
|
||||
```
|
||||
|
||||
Unknown schema versions and structurally incomplete records are rejected. Add
|
||||
an explicit migration function before accepting a future version; never
|
||||
silently reinterpret old data as the newest layout.
|
||||
|
||||
NPC action and profession references are serialized as stable strings and
|
||||
restored as `StringName`. Records referencing unknown executable actions or
|
||||
professions are rejected against the validated definition registry. See
|
||||
[the simulation definitions](SIMULATION_DEFINITIONS.md).
|
||||
|
||||
## Deterministic continuation
|
||||
|
||||
RNG seeds and internal states are encoded as decimal strings. They are signed
|
||||
64-bit values and cannot safely pass through every JSON number implementation
|
||||
without precision loss. Converting them to ordinary JSON numbers caused a
|
||||
restored simulation to retain the same visible state while silently changing
|
||||
its future random sequence.
|
||||
|
||||
`tests/simulation_state_serialization_test.gd` protects this contract by:
|
||||
|
||||
1. running a fixed-seed scenario for 48 ticks without interruption;
|
||||
2. running the same scenario for 24 ticks;
|
||||
3. serializing and restoring into a fresh manager;
|
||||
4. running the remaining 24 ticks;
|
||||
5. requiring the complete final-state checksums to match.
|
||||
|
||||
It also verifies clock remainder, resource amount/reservation/enabled
|
||||
round-tripping, presentation unload/rebind, and rejection of unsupported
|
||||
schemas.
|
||||
|
||||
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
||||
active. Nested v1 NPC records migrate explicitly with no invented active
|
||||
destination. This allows a reloaded visual to resume travel without changing
|
||||
target selection or deterministic RNG state.
|
||||
|
||||
NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
|
||||
StorageStateRecord entries; world-schema v1 migrates legacy village food into
|
||||
the stable `village_pantry` record. Parsed storage values are canonicalized so
|
||||
save/restore continuation retains byte-stable checksums.
|
||||
|
||||
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
||||
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
||||
stream. Event records use stable source/destination/item IDs, reject invalid or
|
||||
duplicate IDs, and preserve deterministic ordering across save/restore. See
|
||||
[the economic event stream](ECONOMIC_EVENTS.md).
|
||||
|
||||
## Resource authority
|
||||
|
||||
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
||||
scene tree. Amount, reservation, enabled state, action/resource identity,
|
||||
yield, usage permissions, and discovery metadata remain available and
|
||||
serializable while no ResourceNode is loaded.
|
||||
|
||||
ResourceNode binds to a record by stable ID and provides presentation,
|
||||
interaction transforms, and active-world discoverability. Unloading a node does
|
||||
not delete its record. Loading another node with the same stable ID rebinds to
|
||||
the existing record and cannot overwrite its amount or reservation with scene
|
||||
defaults.
|
||||
|
||||
ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and
|
||||
`discovery_priority` so unloaded resources keep the same target-selection
|
||||
meaning after save/restore. Nested v1 and v2 resource records are migrated
|
||||
explicitly.
|
||||
|
||||
## Local quicksave boundary
|
||||
|
||||
`SaveSlotStore` writes the existing versioned JSON record to
|
||||
`user://saves/quicksave.json`. It validates the serialized record before
|
||||
replacement, limits file size, restricts slot names, preserves the previous
|
||||
file during replacement, and can recover that backup if replacement is
|
||||
interrupted. Loading parses and validates the complete record before mutating
|
||||
the simulation.
|
||||
|
||||
In `main.tscn`, F5 saves and F9 loads this slot. Restoration rebuilds active NPC
|
||||
visuals from authoritative state.
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
This phase does not yet provide:
|
||||
|
||||
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||
- migrations older than the explicitly supported world schemas v1 and v2;
|
||||
- player inventory, relationship, schedule, or social-memory records;
|
||||
- persistence for the player transform or presentation-only scene state.
|
||||
|
||||
Those features should build on this boundary rather than inventing parallel
|
||||
serialization paths.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Flat-map baseline
|
||||
|
||||
- **Captured:** 2026-07-04
|
||||
- **Scene:** `res://main.tscn`
|
||||
- **Current contract check:** `res://tests/jajce_runtime_integration_test.gd`
|
||||
- **Companion resource test:** `res://tests/resource_node_player_parity_test.gd`
|
||||
|
||||
## Contract
|
||||
|
||||
The captured flat prototype remains the historical behavior reference. The
|
||||
same contract now runs against the integrated `JajceWorld` runtime.
|
||||
|
||||
The automated baseline verifies:
|
||||
|
||||
- the scene creates six simulated NPCs;
|
||||
- eight stable-ID ResourceNodes register;
|
||||
- three fixed origins can reach the typed pantry, three typed activity sites,
|
||||
and eight ResourceNode interaction points (36 route checks);
|
||||
- a two-tick working task completes;
|
||||
- starvation still reaches death at its configured threshold.
|
||||
|
||||
The companion player-parity scenario verifies:
|
||||
|
||||
- player food extraction conserves the actual remaining amount;
|
||||
- player wood extraction uses the configured yield;
|
||||
- player depletion releases an NPC reservation.
|
||||
- player pantry and guard interactions use typed world sites.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files (x86)\Godot\Godot_v4.5.1-stable_win64_console.exe" `
|
||||
--headless --path . --script res://tests/jajce_runtime_integration_test.gd
|
||||
|
||||
& "C:\Program Files (x86)\Godot\Godot_v4.5.1-stable_win64_console.exe" `
|
||||
--headless --path . --script res://tests/resource_node_player_parity_test.gd
|
||||
```
|
||||
|
||||
Godot 4.5.1 is the locally available validation runtime. The project currently
|
||||
declares Godot 4.7 features, so final editor validation should also be performed
|
||||
with the project version when available.
|
||||
|
||||
## Purpose
|
||||
|
||||
This baseline protects behavior, not visual composition. Terrain, lighting,
|
||||
camera framing, and placeholder geometry may change. A Jajce migration is a
|
||||
regression if it breaks these contracts without an explicit replacement test.
|
||||
@@ -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 |
@@ -0,0 +1,82 @@
|
||||
# ADR 0001: Serializable simulation state is authoritative
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-04
|
||||
|
||||
## Context
|
||||
|
||||
The prototype correctly separates `SimNPC` from `NpcVisual`, but authority is
|
||||
still split across simulation records and scene nodes:
|
||||
|
||||
- `ResourceNode` owns remaining amount and reservations;
|
||||
- `WorldViewManager` resolves targets and writes the chosen target ID onto an
|
||||
NPC;
|
||||
- `SimulationManager` combines time, orchestration, action completion, player
|
||||
commands, and event publication;
|
||||
- the visual position of an active NPC can diverge from its stored simulation
|
||||
position.
|
||||
|
||||
This is workable for one loaded flat map. It does not support reliable
|
||||
save/load, unloaded locations, deterministic replay, simulation fidelity
|
||||
changes, or a large data-only population.
|
||||
|
||||
## Decision
|
||||
|
||||
Persistent mutable gameplay state will move into versioned, serializable
|
||||
records owned by a `WorldState`-style simulation model.
|
||||
|
||||
- Definitions contain immutable action, profession, need, item, and location
|
||||
data.
|
||||
- State records contain people, resources, inventories, reservations,
|
||||
locations, current actions, and later relationships and history.
|
||||
- Systems are the only layer that authoritatively mutates those records.
|
||||
- Godot world nodes present records and provide active-world facts such as
|
||||
interaction transforms, navigation results, collision, and animation.
|
||||
- Presentation adapters may submit commands and query results, but may not
|
||||
choose actions or directly author persistent state.
|
||||
- References between records use stable IDs, never `Node`, `NodePath`, or scene
|
||||
ownership.
|
||||
- Active-world position must synchronize through an explicit adapter so
|
||||
unloading a visual does not erase or redefine the NPC's location.
|
||||
|
||||
The simulation core must be runnable headlessly with a fixed clock, controlled
|
||||
random streams, and no dependency on a loaded gameplay scene.
|
||||
|
||||
## Staged migration
|
||||
|
||||
1. ✅ Add a deterministic clock, seeded random source, and scenario runner.
|
||||
2. ✅ Introduce serializable village, NPC, and resource-state records.
|
||||
3. ✅ Bind `ResourceNode` presentation to resource records instead of owning
|
||||
authoritative amount and reservation.
|
||||
4. ✅ Move target choice and reservation coordination out of
|
||||
`WorldViewManager` into an action/target system with an active-world query
|
||||
adapter.
|
||||
5. ✅ Split action selection, execution, and presentation travel.
|
||||
6. ✅ Synchronize active position and prove visual unload/reload
|
||||
invariance.
|
||||
7. Add save-slot persistence before schedules or relationships
|
||||
substantially expand mutable state.
|
||||
|
||||
Schema v1 and its deterministic continuation contract are documented in
|
||||
[the simulation state schema](../SIMULATION_STATE_SCHEMA.md).
|
||||
Stable action/profession identity and immutable metadata are documented in
|
||||
[the simulation definitions](../SIMULATION_DEFINITIONS.md).
|
||||
System responsibilities and event flow are documented in
|
||||
[the action system architecture](../ACTION_SYSTEM_ARCHITECTURE.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Some current prototype APIs are transitional and should not be generalized
|
||||
as final architecture.
|
||||
- The Jajce scaffold may proceed far enough to validate terrain, navigation,
|
||||
and world-object placement, but beauty production pauses at the architecture
|
||||
gate.
|
||||
- New persistent features must define their state record and save behavior.
|
||||
- Headless scenarios become the preferred regression surface for simulation
|
||||
rules.
|
||||
|
||||
## Revisit when
|
||||
|
||||
Revisit this decision only if measured prototype work shows that a different
|
||||
authority model materially simplifies deterministic persistence and simulation
|
||||
LOD without coupling rules to loaded scenes.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Architecture decisions
|
||||
|
||||
Decision records capture choices that should survive individual implementation
|
||||
plans. Use the next sequential number and include:
|
||||
|
||||
- context and problem;
|
||||
- decision;
|
||||
- consequences;
|
||||
- staged migration, if needed;
|
||||
- conditions for revisiting the decision.
|
||||
|
||||
Do not create records for routine edits or choices that are still exploratory.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Local Quality Gate
|
||||
|
||||
A single command to detect broken GDScript, formatting issues, lint problems,
|
||||
parser errors, and LLM-generated garbage before committing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3** with `gdtoolkit` (gdscript-toolkit):
|
||||
```bash
|
||||
pip install gdtoolkit
|
||||
```
|
||||
- **Godot 4** binary on `PATH`, or set the `GODOT_BIN` environment variable
|
||||
|
||||
## Usage
|
||||
|
||||
### Full project check
|
||||
|
||||
```bash
|
||||
# Linux / macOS / Git Bash
|
||||
./tools/quality.sh
|
||||
|
||||
# Windows PowerShell
|
||||
& ./tools/quality.ps1
|
||||
```
|
||||
|
||||
### Fast changed-files mode
|
||||
|
||||
Only runs `gdformat` / `gdlint` on `.gd` files modified since the last commit.
|
||||
The Godot dependency check and all project scenarios still run.
|
||||
|
||||
```bash
|
||||
./tools/quality.sh --changed
|
||||
& ./tools/quality.ps1 -Changed
|
||||
```
|
||||
|
||||
### Auto-format (not part of the gate)
|
||||
|
||||
```bash
|
||||
./tools/fix_format.sh
|
||||
```
|
||||
|
||||
## What it checks
|
||||
|
||||
| Check | Tool | What it detects |
|
||||
|---|---|---|
|
||||
| **gdformat** | Game-owned GDScript roots | Formatting without rewriting addons or demos |
|
||||
| **gdlint** | Game-owned GDScript roots | Lint violations such as unused arguments and naming |
|
||||
| **godot-check** | Headless definitions scenario | Parser errors and missing dependencies |
|
||||
| **scenarios** | Every `tests/*_test.gd` script | Simulation, persistence, navigation, and presentation regressions |
|
||||
| **gut** | `godot -s gut_cmdln.gd` | Failing GUT unit tests (only if addon exists) |
|
||||
|
||||
## Output
|
||||
|
||||
If everything passes:
|
||||
|
||||
```
|
||||
QUALITY RESULT: PASS
|
||||
|
||||
gdformat PASS
|
||||
gdlint PASS
|
||||
godot-check PASS
|
||||
scenarios PASS
|
||||
gut SKIPPED
|
||||
|
||||
Full logs:
|
||||
logs/quality/latest/
|
||||
```
|
||||
|
||||
On failure, only relevant errors and fix suggestions are shown:
|
||||
|
||||
```
|
||||
QUALITY RESULT: FAIL
|
||||
|
||||
gdformat FAIL
|
||||
gdlint FAIL
|
||||
godot-check PASS
|
||||
scenarios PASS
|
||||
gut SKIPPED
|
||||
|
||||
res://simulation/SimNPC.gd:42 Unused argument 'delta'
|
||||
res://simulation/task_manager.gd:88 Line too long (120 > 100)
|
||||
|
||||
NEXT FIX:
|
||||
1. Fix unused-argument in SimNPC.gd
|
||||
2. Run gdformat to auto-format files
|
||||
|
||||
Full logs:
|
||||
logs/quality/latest/
|
||||
```
|
||||
|
||||
Full logs are always written to `logs/quality/latest/` and never dumped
|
||||
to the terminal.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GODOT_BIN` | Path to the Godot executable (auto-detected from PATH if unset) |
|
||||
|
||||
## CI setup
|
||||
|
||||
To add this to CI, install the dependencies and run:
|
||||
|
||||
```bash
|
||||
pip install gdtoolkit
|
||||
./tools/quality.sh
|
||||
```
|
||||
|
||||
The script exits non-zero on any failure, so it will fail the CI step.
|
||||
@@ -5,21 +5,12 @@
|
||||
[ext_resource type="Script" uid="uid://dbb25ttlyogqr" path="res://simulation/SimulationManager.gd" id="3_lquwl"]
|
||||
[ext_resource type="Script" uid="uid://c1f3b26n4yntf" path="res://world/world_view_manager.gd" id="4_1bvp3"]
|
||||
[ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"]
|
||||
[ext_resource type="PackedScene" uid="uid://q0w3en4bfk42" path="res://assets/foliage/tree.glb" id="6_5vw27"]
|
||||
[ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"]
|
||||
[ext_resource type="PackedScene" uid="uid://dyu3r0cl0judq" path="res://assets/foliage/tree-high.glb" id="7_kek77"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"]
|
||||
vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5)
|
||||
polygons = [PackedInt32Array(2, 1, 0), PackedInt32Array(3, 2, 4), PackedInt32Array(4, 2, 5), PackedInt32Array(5, 2, 0), PackedInt32Array(6, 3, 4), PackedInt32Array(8, 7, 6), PackedInt32Array(8, 6, 9), PackedInt32Array(9, 6, 4), PackedInt32Array(12, 11, 10), PackedInt32Array(10, 7, 8), PackedInt32Array(14, 13, 12), PackedInt32Array(10, 8, 12), PackedInt32Array(12, 8, 14), PackedInt32Array(14, 8, 15), PackedInt32Array(0, 1, 16), PackedInt32Array(16, 13, 14), PackedInt32Array(14, 17, 16), PackedInt32Array(16, 17, 0)]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_o5qli"]
|
||||
albedo_color = Color(0.16862746, 0.52156866, 0.30588236, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_sgp6g"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_lquwl"]
|
||||
size = Vector3(30, 0.1, 30)
|
||||
[ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"]
|
||||
[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" 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
|
||||
@@ -29,63 +20,18 @@ height = 1.7
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_0wfyh"]
|
||||
|
||||
[sub_resource type="Environment" id="Environment_o5qli"]
|
||||
background_mode = 1
|
||||
background_color = Color(0.46428233, 0.7990817, 0.98692364, 1)
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(1, 1, 1, 1)
|
||||
ambient_light_energy = 0.5
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_5vw27"]
|
||||
albedo_color = Color(0.5328383, 0.4215183, 0.18649894, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_5vw27"]
|
||||
material = SubResource("StandardMaterial3D_5vw27")
|
||||
|
||||
[sub_resource type="CylinderMesh" id="CylinderMesh_5vw27"]
|
||||
top_radius = 1.129
|
||||
height = 5.774
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_kek77"]
|
||||
|
||||
[sub_resource type="PrismMesh" id="PrismMesh_5vw27"]
|
||||
size = Vector3(3, 5, 2)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_4c57u"]
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1850341560]
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1256679786]
|
||||
transform = Transform3D(0.8660254, -0.43301272, 0.24999999, 0, 0.49999997, 0.86602545, -0.5, -0.75, 0.43301266, 0, 0, 0)
|
||||
light_energy = 2.0
|
||||
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
|
||||
|
||||
[node name="World" type="Node3D" parent="." unique_id=1151232221]
|
||||
|
||||
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World" unique_id=166422415]
|
||||
navigation_mesh = SubResource("NavigationMesh_lquwl")
|
||||
|
||||
[node name="GroundRoot" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1002581689]
|
||||
|
||||
[node name="GroundMesh" type="MeshInstance3D" parent="World/NavigationRegion3D/GroundRoot" unique_id=958336203]
|
||||
transform = Transform3D(30, 0, 0, 0, 0.1, 0, 0, 0, 30, 0, -0.05, 0)
|
||||
material_override = SubResource("StandardMaterial3D_o5qli")
|
||||
mesh = SubResource("BoxMesh_sgp6g")
|
||||
skeleton = NodePath("../../..")
|
||||
|
||||
[node name="GroundCollision" type="CollisionShape3D" parent="World/NavigationRegion3D/GroundRoot" unique_id=1249304562]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.18380833, 0)
|
||||
shape = SubResource("BoxShape3D_lquwl")
|
||||
|
||||
[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "farm_zone", "forest_zone", "food_zone", "guard_zone", "study_zone")]
|
||||
[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)
|
||||
script = ExtResource("1_h2yge")
|
||||
camera_rig = NodePath("../CameraRig")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
farm_zone = NodePath("../TaskZone/FarmZone")
|
||||
forest_zone = NodePath("../TaskZone/ForestZone")
|
||||
food_zone = NodePath("../TaskZone/FoodZone")
|
||||
guard_zone = NodePath("../TaskZone/GuardZone")
|
||||
study_zone = NodePath("../TaskZone/StudyZone")
|
||||
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
|
||||
guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost")
|
||||
study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk")
|
||||
stomach_capacity_for_food = 10
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1249497228]
|
||||
@@ -107,90 +53,36 @@ 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="SimulationManager" type="Node" parent="." unique_id=1099676814]
|
||||
script = ExtResource("3_lquwl")
|
||||
[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="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "farm_zone", "forest_zone", "guard_zone", "study_zone", "rest_zone", "food_zone")]
|
||||
[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="." unique_id=1281154019 node_paths=PackedStringArray("simulation_manager")]
|
||||
script = ExtResource("10_save")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
|
||||
[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent")]
|
||||
script = ExtResource("4_1bvp3")
|
||||
npc_visual_scene = ExtResource("5_lquwl")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
active_npcs_parent = NodePath("../ActiveNPCs")
|
||||
farm_zone = NodePath("../TaskZone/FarmZone")
|
||||
forest_zone = NodePath("../TaskZone/ForestZone")
|
||||
guard_zone = NodePath("../TaskZone/GuardZone")
|
||||
study_zone = NodePath("../TaskZone/StudyZone")
|
||||
rest_zone = NodePath("../TaskZone/RestZone")
|
||||
food_zone = NodePath("../TaskZone/FoodZone")
|
||||
|
||||
[node name="EventBus" type="Node" parent="." unique_id=1149294963]
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=662717542]
|
||||
environment = SubResource("Environment_o5qli")
|
||||
|
||||
[node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680]
|
||||
|
||||
[node name="TaskZone" type="Node3D" parent="." unique_id=1700020332]
|
||||
|
||||
[node name="FarmZone" type="Marker3D" parent="TaskZone" unique_id=786703634]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 0, -6)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/FarmZone" unique_id=35196509]
|
||||
transform = Transform3D(2.0639582, 0, 0, 0, 0.2, 0, 0, 0, 2.211021, -0.47271156, 0.47702956, -0.02484417)
|
||||
mesh = SubResource("BoxMesh_5vw27")
|
||||
|
||||
[node name="ForestZone" type="Marker3D" parent="TaskZone" unique_id=1194368101]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 0, -6)
|
||||
|
||||
[node name="smallTree" parent="TaskZone/ForestZone" unique_id=1125781625 instance=ExtResource("6_5vw27")]
|
||||
transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, 1.891, 0, -0.22)
|
||||
|
||||
[node name="smallTree2" parent="TaskZone/ForestZone" unique_id=1145526891 instance=ExtResource("6_5vw27")]
|
||||
transform = Transform3D(2, 0, 0, 0, 2, 0, 0, 0, 2, -0.043, 0, -1.767)
|
||||
|
||||
[node name="smallTree3" parent="TaskZone/ForestZone" unique_id=759036058 instance=ExtResource("6_5vw27")]
|
||||
transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, 0.13, 0, 0.614)
|
||||
|
||||
[node name="highTree" parent="TaskZone/ForestZone" unique_id=1321501180 instance=ExtResource("7_kek77")]
|
||||
transform = Transform3D(3, 0, 0, 0, 3, 0, 0, 0, 3, -1.539, 0, 0.171)
|
||||
|
||||
[node name="GuardZone" type="Marker3D" parent="TaskZone" unique_id=1391821683]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/GuardZone" unique_id=2044200724]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.354011, 0)
|
||||
mesh = SubResource("CylinderMesh_5vw27")
|
||||
|
||||
[node name="StudyZone" type="Marker3D" parent="TaskZone" unique_id=1061191663]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, 6)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/StudyZone" unique_id=338918224]
|
||||
transform = Transform3D(3.279775, 0, 0, 0, 5.511466, 0, 0, 0, 1, 0, 2.8180213, 0)
|
||||
mesh = SubResource("BoxMesh_kek77")
|
||||
metadata/_edit_group_ = true
|
||||
|
||||
[node name="RestZone" type="Marker3D" parent="TaskZone" unique_id=1187838016]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 0, 5)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/RestZone" unique_id=299533907]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.4911091, 0)
|
||||
mesh = SubResource("PrismMesh_5vw27")
|
||||
|
||||
[node name="MeshInstance3D2" type="MeshInstance3D" parent="TaskZone/RestZone" unique_id=943692640]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3897309, 1.399735, 1.0352364)
|
||||
mesh = SubResource("PrismMesh_5vw27")
|
||||
|
||||
[node name="FoodZone" type="Marker3D" parent="TaskZone" unique_id=780921054]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -8)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="TaskZone/FoodZone" unique_id=357423915]
|
||||
transform = Transform3D(4, 0, 0, 0, 3, 0, 0, 0, 4, 0, 1.5, 0)
|
||||
mesh = SubResource("BoxMesh_4c57u")
|
||||
|
||||
[node name="UI" type="CanvasLayer" parent="." unique_id=875790201 node_paths=PackedStringArray("simulation_manager")]
|
||||
script = ExtResource("6_7mycd")
|
||||
simulation_manager = NodePath("../SimulationManager")
|
||||
@@ -211,3 +103,40 @@ theme_override_constants/margin_bottom = 4
|
||||
[node name="VillageStatsLabel" type="Label" parent="UI/VillagePanel/MarginContainer" unique_id=1554319259]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 10
|
||||
|
||||
[node name="NpcInspectorPanel" type="PanelContainer" parent="UI" unique_id=1165556983]
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -370.0
|
||||
offset_top = 20.0
|
||||
offset_right = -20.0
|
||||
offset_bottom = 330.0
|
||||
grow_horizontal = 0
|
||||
|
||||
[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" 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" 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")
|
||||
|
||||
[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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://rs3hv73svbpa
|
||||
@@ -1 +0,0 @@
|
||||
extends Node
|
||||
@@ -1 +0,0 @@
|
||||
uid://b1ka4yih1ioh
|
||||
+41
-4
@@ -6,15 +6,23 @@ 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
|
||||
|
||||
@export var deadzone_right := 3.0
|
||||
@export var deadzone_forward := 3.0
|
||||
|
||||
@export var min_zoom_offset := Vector3(0, 5, 4)
|
||||
@export var max_zoom_offset := Vector3(0, 16, 12)
|
||||
@export var zoom_step := 0.1
|
||||
|
||||
var yaw := 0.0
|
||||
var smoothed_yaw := 0.0
|
||||
|
||||
var zoom := 0.5
|
||||
var zoom_smooth := 0.5
|
||||
|
||||
var focus_position := Vector3.ZERO
|
||||
var desired_focus_position := Vector3.ZERO
|
||||
|
||||
@@ -25,13 +33,19 @@ func _ready() -> void:
|
||||
if target:
|
||||
focus_position = target.global_position
|
||||
desired_focus_position = focus_position
|
||||
global_position = focus_position + follow_offset
|
||||
_apply_camera_transform()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion:
|
||||
yaw -= event.relative.x * mouse_sensitivity
|
||||
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
|
||||
zoom = maxf(zoom - zoom_step, 0.0)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
|
||||
zoom = minf(zoom + zoom_step, 1.0)
|
||||
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
|
||||
@@ -46,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
|
||||
@@ -61,11 +75,34 @@ func _physics_process(delta: float) -> void:
|
||||
desired_focus_position += cam_right * (diff_right - sign(diff_right) * deadzone_right)
|
||||
|
||||
if abs(diff_forward) > deadzone_forward:
|
||||
desired_focus_position += cam_forward * (diff_forward - sign(diff_forward) * deadzone_forward)
|
||||
desired_focus_position += (
|
||||
cam_forward * (diff_forward - sign(diff_forward) * deadzone_forward)
|
||||
)
|
||||
|
||||
desired_focus_position.y = target_pos.y
|
||||
|
||||
focus_position = focus_position.lerp(desired_focus_position, focus_t)
|
||||
|
||||
var rotated_offset := follow_offset.rotated(Vector3.UP, smoothed_yaw)
|
||||
zoom_smooth = lerpf(zoom_smooth, zoom, follow_t)
|
||||
var active_offset := min_zoom_offset.lerp(max_zoom_offset, zoom_smooth)
|
||||
|
||||
var rotated_offset := active_offset.rotated(Vector3.UP, smoothed_yaw)
|
||||
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)
|
||||
|
||||
+233
-8
@@ -1,44 +1,147 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
signal arrived_at_target(sim_id: int)
|
||||
signal navigation_failed(sim_id: int)
|
||||
signal position_changed(sim_id: int, active_position: Vector3)
|
||||
|
||||
const DEBUG_LOGS := true
|
||||
|
||||
@export var move_speed := 3.0
|
||||
@export var rotation_speed := 8.0
|
||||
@export var waypoint_reached_distance := 0.35
|
||||
@export var stuck_timeout := 1.5
|
||||
|
||||
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||
@onready var body_mesh: MeshInstance3D = $MeshInstance3D
|
||||
@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
|
||||
var npc_name: String = ""
|
||||
var profession: String = ""
|
||||
var profession: StringName
|
||||
|
||||
var current_path: PackedVector3Array = []
|
||||
var path_index := 0
|
||||
var current_target := Vector3.INF
|
||||
var arrival_distance := 0.6
|
||||
var stuck_time := 0.0
|
||||
var path_request_id := 0
|
||||
var path_pending := false
|
||||
|
||||
var is_dead_visual := false
|
||||
var dead_material := StandardMaterial3D.new()
|
||||
var debug_overlay_visible := true
|
||||
|
||||
|
||||
func debug_log(message: String) -> void:
|
||||
if DEBUG_LOGS:
|
||||
print("[NPCVisual] ", name, " | ", message)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0)
|
||||
|
||||
|
||||
func setup_from_sim(npc: SimNPC) -> void:
|
||||
sim_id = npc.id
|
||||
npc_name = npc.npc_name
|
||||
profession = npc.profession
|
||||
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:
|
||||
var definition := SimulationDefinitions.get_profession(profession)
|
||||
if definition == null:
|
||||
profession_label.text = npc_name
|
||||
profession_prop.visible = false
|
||||
return
|
||||
|
||||
var body_material := StandardMaterial3D.new()
|
||||
body_material.albedo_color = definition.visual_color
|
||||
body_material.roughness = 0.82
|
||||
body_mesh.material_override = body_material
|
||||
|
||||
var prop_material := StandardMaterial3D.new()
|
||||
prop_material.albedo_color = definition.prop_color
|
||||
prop_material.roughness = 0.78
|
||||
profession_prop.material_override = prop_material
|
||||
profession_prop.mesh = _create_profession_prop_mesh()
|
||||
profession_prop.visible = profession_prop.mesh != null
|
||||
profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
|
||||
profession_label.visible = debug_overlay_visible
|
||||
|
||||
|
||||
func _create_profession_prop_mesh() -> PrimitiveMesh:
|
||||
profession_prop.position = Vector3(0.48, 0.85, 0.0)
|
||||
profession_prop.rotation_degrees = Vector3.ZERO
|
||||
match profession:
|
||||
SimulationIds.PROFESSION_FARMER:
|
||||
var basket := SphereMesh.new()
|
||||
basket.radius = 0.24
|
||||
basket.height = 0.34
|
||||
return basket
|
||||
SimulationIds.PROFESSION_WOODCUTTER:
|
||||
var tool := BoxMesh.new()
|
||||
tool.size = Vector3(0.12, 0.72, 0.16)
|
||||
profession_prop.position = Vector3(0.48, 0.92, 0.0)
|
||||
profession_prop.rotation_degrees.z = -18.0
|
||||
return tool
|
||||
SimulationIds.PROFESSION_GUARD:
|
||||
var shield := CylinderMesh.new()
|
||||
shield.top_radius = 0.3
|
||||
shield.bottom_radius = 0.3
|
||||
shield.height = 0.12
|
||||
profession_prop.rotation_degrees.x = 90.0
|
||||
return shield
|
||||
SimulationIds.PROFESSION_SCHOLAR:
|
||||
var book := BoxMesh.new()
|
||||
book.size = Vector3(0.42, 0.1, 0.3)
|
||||
return book
|
||||
SimulationIds.PROFESSION_WANDERER:
|
||||
var pack := SphereMesh.new()
|
||||
pack.radius = 0.28
|
||||
pack.height = 0.5
|
||||
profession_prop.position = Vector3(-0.42, 0.86, 0.0)
|
||||
return pack
|
||||
return null
|
||||
|
||||
|
||||
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:
|
||||
profession_label.visible = is_visible and not is_dead_visual
|
||||
|
||||
|
||||
func set_target_position(pos: Vector3) -> void:
|
||||
path_request_id += 1
|
||||
var request_id := path_request_id
|
||||
current_target = pos
|
||||
has_reported_arrival = false
|
||||
stuck_time = 0.0
|
||||
path_pending = false
|
||||
|
||||
if global_position.distance_to(pos) <= arrival_distance:
|
||||
current_path = PackedVector3Array()
|
||||
@@ -47,23 +150,27 @@ func set_target_position(pos: Vector3) -> void:
|
||||
return
|
||||
|
||||
nav_agent.target_position = pos
|
||||
path_pending = true
|
||||
|
||||
await get_tree().physics_frame
|
||||
|
||||
if request_id != path_request_id or is_dead_visual:
|
||||
return
|
||||
|
||||
current_path = NavigationServer3D.map_get_path(
|
||||
get_world_3d().navigation_map,
|
||||
global_position,
|
||||
pos,
|
||||
true
|
||||
get_world_3d().navigation_map, global_position, pos, true
|
||||
)
|
||||
|
||||
path_pending = false
|
||||
path_index = 0
|
||||
|
||||
if current_path.is_empty():
|
||||
debug_log("Path is empty. Will report arrival/failure fallback.")
|
||||
debug_log("Path is empty")
|
||||
_report_navigation_failure_once()
|
||||
else:
|
||||
debug_log("New target: %s Path points: %s" % [pos, current_path.size()])
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if is_dead_visual:
|
||||
velocity = Vector3.ZERO
|
||||
@@ -75,6 +182,11 @@ func _physics_process(delta: float) -> void:
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
if path_pending:
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
if global_position.distance_to(current_target) <= arrival_distance:
|
||||
_report_arrival_once()
|
||||
velocity = Vector3.ZERO
|
||||
@@ -84,27 +196,44 @@ func _physics_process(delta: float) -> void:
|
||||
if current_path.is_empty() or path_index >= current_path.size():
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
_report_navigation_failure_once()
|
||||
return
|
||||
|
||||
var next_pos := current_path[path_index]
|
||||
var flat_next_pos := Vector3(next_pos.x, global_position.y, next_pos.z)
|
||||
var to_next := flat_next_pos - global_position
|
||||
var to_next := next_pos - global_position
|
||||
|
||||
if to_next.length() <= waypoint_reached_distance:
|
||||
path_index += 1
|
||||
return
|
||||
|
||||
var direction := to_next.normalized()
|
||||
var prev_pos := global_position
|
||||
|
||||
velocity.x = direction.x * move_speed
|
||||
velocity.z = direction.z * move_speed
|
||||
if not is_on_floor():
|
||||
velocity.y -= 30.0 * delta
|
||||
else:
|
||||
velocity.y = 0.0
|
||||
|
||||
move_and_slide()
|
||||
if not global_position.is_equal_approx(prev_pos):
|
||||
position_changed.emit(sim_id, global_position)
|
||||
|
||||
var minimum_progress := move_speed * delta * 0.1
|
||||
if global_position.distance_squared_to(prev_pos) < minimum_progress * minimum_progress:
|
||||
stuck_time += delta
|
||||
if stuck_time >= stuck_timeout:
|
||||
debug_log("Movement remained blocked")
|
||||
_report_navigation_failure_once()
|
||||
return
|
||||
else:
|
||||
stuck_time = 0.0
|
||||
|
||||
var target_angle := atan2(direction.x, direction.z)
|
||||
rotation.y = lerp_angle(rotation.y, target_angle, rotation_speed * delta)
|
||||
|
||||
|
||||
func _report_arrival_once() -> void:
|
||||
if has_reported_arrival:
|
||||
return
|
||||
@@ -116,12 +245,34 @@ func _report_arrival_once() -> void:
|
||||
debug_log("Arrived at target")
|
||||
arrived_at_target.emit(sim_id)
|
||||
|
||||
|
||||
func _report_navigation_failure_once() -> void:
|
||||
if has_reported_arrival:
|
||||
return
|
||||
|
||||
has_reported_arrival = true
|
||||
current_path = PackedVector3Array()
|
||||
path_index = 0
|
||||
current_target = Vector3.INF
|
||||
path_pending = false
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
debug_log("Navigation failed")
|
||||
navigation_failed.emit(sim_id)
|
||||
|
||||
|
||||
func apply_dead_visual_state() -> void:
|
||||
is_dead_visual = true
|
||||
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()
|
||||
path_index = 0
|
||||
current_target = Vector3.INF
|
||||
path_pending = false
|
||||
has_reported_arrival = true
|
||||
|
||||
scale = Vector3(0.8, 0.25, 1.2)
|
||||
@@ -134,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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=6 format=3 uid="uid://dhxxyprqflotq"]
|
||||
[gd_scene load_steps=8 format=3 uid="uid://dhxxyprqflotq"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"]
|
||||
|
||||
@@ -12,7 +12,18 @@ albedo_color = Color(1, 0.22352941, 1, 1)
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_d844k"]
|
||||
material = SubResource("StandardMaterial3D_d844k")
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_food"]
|
||||
albedo_color = Color(0.38, 0.22, 0.08, 1)
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_food"]
|
||||
material = SubResource("StandardMaterial3D_food")
|
||||
radius = 0.28
|
||||
height = 0.48
|
||||
|
||||
[node name="NpcVisual" type="CharacterBody3D"]
|
||||
collision_layer = 2
|
||||
collision_mask = 1
|
||||
script = ExtResource("1_ixfoq")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
@@ -27,6 +38,29 @@ mesh = SubResource("CapsuleMesh_d844k")
|
||||
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.35, 0, 1.2, 0.59052056)
|
||||
mesh = SubResource("BoxMesh_d844k")
|
||||
|
||||
[node name="ProfessionProp" type="MeshInstance3D" parent="."]
|
||||
|
||||
[node name="ProfessionLabel" type="Label3D" parent="."]
|
||||
position = Vector3(0, 2.05, 0)
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
text = "Villager"
|
||||
font_size = 24
|
||||
outline_size = 6
|
||||
modulate = Color(1, 0.96, 0.82, 1)
|
||||
pixel_size = 0.007
|
||||
|
||||
[node name="CarriedFood" type="MeshInstance3D" parent="."]
|
||||
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
|
||||
|
||||
+57
-21
@@ -7,22 +7,16 @@ extends CharacterBody3D
|
||||
|
||||
@export var simulation_manager: Node
|
||||
|
||||
@export var farm_zone: Marker3D
|
||||
@export var forest_zone: Marker3D
|
||||
@export var food_zone: Marker3D
|
||||
@export var guard_zone: Marker3D
|
||||
@export var study_zone: Marker3D
|
||||
@export var pantry_storage: StorageNode
|
||||
@export var guard_site: ActivitySite
|
||||
@export var study_site: ActivitySite
|
||||
|
||||
@export var stomach_capacity_for_food := 5
|
||||
@export var interaction_range := 3.0
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
var input := Input.get_vector(
|
||||
"move_left",
|
||||
"move_right",
|
||||
"move_forward",
|
||||
"move_backward"
|
||||
)
|
||||
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_backward")
|
||||
|
||||
var forward := -camera_rig.global_transform.basis.z
|
||||
var right := camera_rig.global_transform.basis.x
|
||||
@@ -62,24 +56,66 @@ func try_interact() -> void:
|
||||
if simulation_manager == null:
|
||||
return
|
||||
|
||||
if is_near(farm_zone):
|
||||
simulation_manager.add_food(5.0)
|
||||
print("Player gathered food for the village.")
|
||||
elif is_near(forest_zone):
|
||||
simulation_manager.add_wood(5.0)
|
||||
print("Player gathered wood for the village.")
|
||||
elif is_near(guard_zone):
|
||||
if try_harvest_resource_node():
|
||||
return
|
||||
|
||||
if is_near_activity_site(guard_site):
|
||||
simulation_manager.add_safety(3.0)
|
||||
print("Player helped guard the village.")
|
||||
elif is_near(study_zone):
|
||||
elif is_near_activity_site(study_site):
|
||||
simulation_manager.add_knowledge(2.0)
|
||||
print("Player shared knowledge.")
|
||||
elif is_near(food_zone):
|
||||
elif is_near_storage(pantry_storage):
|
||||
simulation_manager.eat_food(stomach_capacity_for_food)
|
||||
print("Player eating shit up.")
|
||||
print("Player ate food from the village supply.")
|
||||
|
||||
|
||||
func try_harvest_resource_node() -> bool:
|
||||
if not simulation_manager.has_method("find_resource_node_for_player"):
|
||||
push_error("Player: SimulationManager cannot find ResourceNodes")
|
||||
return false
|
||||
var node: ResourceNode = simulation_manager.find_resource_node_for_player(
|
||||
global_position, interaction_range
|
||||
)
|
||||
if node == null:
|
||||
return false
|
||||
|
||||
if not node.is_enabled():
|
||||
print("%s is unavailable." % node.node_id)
|
||||
return true
|
||||
|
||||
if node.is_depleted():
|
||||
print("%s is depleted." % node.node_id)
|
||||
return true
|
||||
|
||||
if not simulation_manager.has_method("harvest_resource_node"):
|
||||
push_error("Player: SimulationManager cannot harvest ResourceNodes")
|
||||
return true
|
||||
|
||||
var extracted: float = simulation_manager.harvest_resource_node(node)
|
||||
if extracted > 0.0:
|
||||
print("Player gathered %s %s from %s." % [extracted, node.resource_id, node.node_id])
|
||||
else:
|
||||
print("%s could not be harvested." % node.node_id)
|
||||
return true
|
||||
|
||||
|
||||
func is_near(zone: Marker3D) -> bool:
|
||||
if zone == null:
|
||||
return false
|
||||
|
||||
return global_position.distance_to(zone.global_position) <= interaction_range
|
||||
|
||||
|
||||
func is_near_storage(storage_node: StorageNode) -> bool:
|
||||
if storage_node == null:
|
||||
return false
|
||||
|
||||
return global_position.distance_to(storage_node.get_interaction_position()) <= interaction_range
|
||||
|
||||
|
||||
func is_near_activity_site(activity_site: ActivitySite) -> bool:
|
||||
if activity_site == null:
|
||||
return false
|
||||
|
||||
return global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range
|
||||
|
||||
+58
-203
@@ -1,251 +1,106 @@
|
||||
class_name SimNPC
|
||||
extends RefCounted
|
||||
|
||||
const DEBUG_LOGS := true
|
||||
|
||||
const TASK_STATE_IDLE := "idle"
|
||||
const TASK_STATE_TRAVELING := "traveling"
|
||||
const TASK_STATE_WORKING := "working"
|
||||
const TASK_STATE_COMPLETE := "complete"
|
||||
const TASK_STATE_IDLE := &"idle"
|
||||
const TASK_STATE_TRAVELING := &"traveling"
|
||||
const TASK_STATE_WORKING := &"working"
|
||||
const TASK_STATE_COMPLETE := &"complete"
|
||||
|
||||
var id: int
|
||||
var npc_name: String
|
||||
var profession: String
|
||||
var profession: StringName
|
||||
var hunger: float
|
||||
var energy: float
|
||||
var strength: float
|
||||
var intelligence: float
|
||||
var current_task: String = "idle"
|
||||
var task_state: String = TASK_STATE_IDLE
|
||||
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
|
||||
var task_progress := 0.0
|
||||
var task_complete := true
|
||||
var target_id: StringName = &""
|
||||
var travel_target_position: Vector3
|
||||
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
|
||||
|
||||
|
||||
func _init(
|
||||
_id: int,
|
||||
_npc_name: String,
|
||||
_profession: String,
|
||||
_profession: StringName,
|
||||
_strength: float,
|
||||
_intelligence: float
|
||||
_intelligence: float,
|
||||
_random_source: RandomNumberGenerator = null
|
||||
) -> void:
|
||||
id = _id
|
||||
npc_name = _npc_name
|
||||
profession = _profession
|
||||
strength = _strength
|
||||
intelligence = _intelligence
|
||||
hunger = randf_range(20.0, 80.0)
|
||||
energy = randf_range(40.0, 100.0)
|
||||
position = Vector3(randf_range(-8.0, 8.0), 0.0, randf_range(-8.0, 8.0))
|
||||
random_source = _random_source
|
||||
if random_source == null:
|
||||
random_source = RandomNumberGenerator.new()
|
||||
random_source.seed = id + 1
|
||||
hunger = random_source.randf_range(20.0, 80.0)
|
||||
energy = random_source.randf_range(40.0, 100.0)
|
||||
position = Vector3(
|
||||
random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0)
|
||||
)
|
||||
home_position = position
|
||||
|
||||
func simulate_tick(village: SimVillage) -> void:
|
||||
if is_dead:
|
||||
return
|
||||
|
||||
update_needs(village)
|
||||
|
||||
if is_dead:
|
||||
return
|
||||
|
||||
if task_state == TASK_STATE_IDLE or task_state == TASK_STATE_COMPLETE:
|
||||
choose_new_task(village)
|
||||
return
|
||||
|
||||
if task_state == TASK_STATE_TRAVELING:
|
||||
return
|
||||
|
||||
if task_state == TASK_STATE_WORKING:
|
||||
task_progress += 1.0
|
||||
|
||||
if task_progress >= task_duration:
|
||||
task_complete = true
|
||||
task_state = TASK_STATE_COMPLETE
|
||||
|
||||
func update_needs(village: SimVillage) -> void:
|
||||
hunger += 3.0 * village.food_modifier
|
||||
|
||||
match task_state:
|
||||
TASK_STATE_IDLE:
|
||||
energy -= 0.5
|
||||
TASK_STATE_TRAVELING:
|
||||
energy -= 2.0
|
||||
TASK_STATE_WORKING:
|
||||
if current_task != "rest":
|
||||
energy -= 3.0
|
||||
TASK_STATE_COMPLETE:
|
||||
energy -= 0.25
|
||||
|
||||
hunger = clamp(hunger, 0.0, 100.0)
|
||||
energy = clamp(energy, 0.0, 100.0)
|
||||
|
||||
is_starving = hunger >= 90.0
|
||||
|
||||
if is_starving:
|
||||
starvation_ticks += 1
|
||||
else:
|
||||
starvation_ticks = 0
|
||||
|
||||
if starvation_ticks >= starvation_death_threshold:
|
||||
die_from_starvation()
|
||||
|
||||
func die_from_starvation() -> void:
|
||||
is_dead = true
|
||||
current_task = "dead"
|
||||
current_task = SimulationIds.ACTION_DEAD
|
||||
task_state = TASK_STATE_IDLE
|
||||
task_progress = 0.0
|
||||
task_duration = 0.0
|
||||
task_complete = true
|
||||
has_travel_target = false
|
||||
|
||||
if DEBUG_LOGS:
|
||||
if debug_logs:
|
||||
print("[SimNPC] ", npc_name, " died from starvation.")
|
||||
|
||||
func choose_new_task(village: SimVillage) -> void:
|
||||
if is_dead:
|
||||
|
||||
func get_inventory_amount(item_id: StringName) -> float:
|
||||
return float(inventory.get(String(item_id), 0.0))
|
||||
|
||||
|
||||
func add_inventory(item_id: StringName, amount: float) -> void:
|
||||
inventory[String(item_id)] = get_inventory_amount(item_id) + maxf(amount, 0.0)
|
||||
|
||||
|
||||
func remove_inventory(item_id: StringName, requested_amount: float) -> float:
|
||||
var removed := minf(maxf(requested_amount, 0.0), get_inventory_amount(item_id))
|
||||
inventory[String(item_id)] = get_inventory_amount(item_id) - removed
|
||||
return removed
|
||||
|
||||
|
||||
func set_task(action_id: StringName, duration: float = -1.0) -> void:
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
if definition == null:
|
||||
push_error("SimNPC: unknown action_id '%s'" % action_id)
|
||||
return
|
||||
|
||||
if is_starving:
|
||||
if village.food > 0:
|
||||
set_task("eat", 1.0)
|
||||
else:
|
||||
set_task("gather_food", 4.0)
|
||||
return
|
||||
|
||||
if hunger > 75.0:
|
||||
if village.food > 0:
|
||||
set_task("eat", 2.0)
|
||||
else:
|
||||
set_task("gather_food", 5.0)
|
||||
return
|
||||
|
||||
if energy < 25.0:
|
||||
set_task("rest", 4.0)
|
||||
return
|
||||
|
||||
var best_task := choose_best_work_task(village)
|
||||
|
||||
match best_task:
|
||||
"gather_food":
|
||||
set_task("gather_food", 5.0)
|
||||
"gather_wood":
|
||||
set_task("gather_wood", 5.0)
|
||||
"patrol":
|
||||
set_task("patrol", 6.0)
|
||||
"study":
|
||||
set_task("study", 6.0)
|
||||
_:
|
||||
set_task("wander", 4.0)
|
||||
|
||||
func should_eat_immediately_after_task() -> bool:
|
||||
return current_task == "gather_food" and is_starving and not is_dead
|
||||
|
||||
func choose_best_work_task(village: SimVillage) -> String:
|
||||
var food_score := calculate_task_score(
|
||||
"gather_food",
|
||||
village.food_priority,
|
||||
village.food,
|
||||
15.0,
|
||||
30.0,
|
||||
3.0,
|
||||
1.5,
|
||||
"farmer"
|
||||
)
|
||||
|
||||
var wood_score := calculate_task_score(
|
||||
"gather_wood",
|
||||
village.wood_priority,
|
||||
village.wood,
|
||||
10.0,
|
||||
20.0,
|
||||
2.5,
|
||||
1.0,
|
||||
"woodcutter"
|
||||
)
|
||||
|
||||
var safety_score := calculate_task_score(
|
||||
"patrol",
|
||||
village.safety_priority,
|
||||
village.safety,
|
||||
30.0,
|
||||
50.0,
|
||||
3.0,
|
||||
1.5,
|
||||
"guard"
|
||||
)
|
||||
|
||||
var knowledge_score := calculate_task_score(
|
||||
"study",
|
||||
village.knowledge_priority,
|
||||
village.knowledge,
|
||||
10.0,
|
||||
25.0,
|
||||
1.5,
|
||||
0.75,
|
||||
"scholar"
|
||||
)
|
||||
|
||||
var best_task := "gather_food"
|
||||
var best_score := food_score
|
||||
|
||||
if wood_score > best_score:
|
||||
best_task = "gather_wood"
|
||||
best_score = wood_score
|
||||
|
||||
if safety_score > best_score:
|
||||
best_task = "patrol"
|
||||
best_score = safety_score
|
||||
|
||||
if knowledge_score > best_score:
|
||||
best_task = "study"
|
||||
best_score = knowledge_score
|
||||
|
||||
return best_task
|
||||
|
||||
func calculate_task_score(
|
||||
task_name: String,
|
||||
base_priority: float,
|
||||
resource_amount: float,
|
||||
critical_threshold: float,
|
||||
low_threshold: float,
|
||||
critical_bonus: float,
|
||||
low_bonus: float,
|
||||
preferred_profession: String
|
||||
) -> float:
|
||||
var score := base_priority
|
||||
|
||||
if resource_amount < critical_threshold:
|
||||
score += critical_bonus
|
||||
elif resource_amount < low_threshold:
|
||||
score += low_bonus
|
||||
|
||||
if profession == preferred_profession:
|
||||
score += 2.0
|
||||
|
||||
score += randf_range(0.0, 0.5)
|
||||
|
||||
if DEBUG_LOGS:
|
||||
print(
|
||||
"[SimNPC] ",
|
||||
npc_name,
|
||||
" score ",
|
||||
task_name,
|
||||
" = ",
|
||||
score
|
||||
)
|
||||
|
||||
return score
|
||||
|
||||
func set_task(task: String, duration: float) -> void:
|
||||
current_task = task
|
||||
task_duration = duration
|
||||
current_task = action_id
|
||||
task_duration = (duration if duration >= 0.0 else definition.default_duration)
|
||||
task_progress = 0.0
|
||||
task_complete = false
|
||||
task_state = TASK_STATE_TRAVELING
|
||||
has_travel_target = false
|
||||
|
||||
|
||||
func start_working() -> void:
|
||||
if task_state != TASK_STATE_TRAVELING:
|
||||
|
||||
+45
-35
@@ -1,12 +1,11 @@
|
||||
class_name SimVillage
|
||||
extends RefCounted
|
||||
|
||||
const DEBUG_LOGS := true
|
||||
|
||||
var food := 20.0
|
||||
var wood := 10.0
|
||||
var safety := 50.0
|
||||
var knowledge := 0.0
|
||||
var debug_logs := true
|
||||
|
||||
var food_modifier := 1.0
|
||||
var wood_modifier := 1.0
|
||||
@@ -18,6 +17,7 @@ var wood_priority := 1.0
|
||||
var safety_priority := 1.0
|
||||
var knowledge_priority := 1.0
|
||||
|
||||
|
||||
func update_modifiers() -> void:
|
||||
food_modifier = 1.0
|
||||
wood_modifier = 1.0
|
||||
@@ -32,13 +32,13 @@ func update_modifiers() -> void:
|
||||
if knowledge > 25:
|
||||
knowledge_modifier = 0.75
|
||||
|
||||
debug_log("FoodMod=%s SafetyMod=%s KnowledgeMod=%s" %
|
||||
[
|
||||
food_modifier,
|
||||
safety_modifier,
|
||||
knowledge_modifier
|
||||
]
|
||||
debug_log(
|
||||
(
|
||||
"FoodMod=%s SafetyMod=%s KnowledgeMod=%s"
|
||||
% [food_modifier, safety_modifier, knowledge_modifier]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func update_priorities() -> void:
|
||||
food_priority = 1.0
|
||||
@@ -66,9 +66,30 @@ func update_priorities() -> void:
|
||||
elif knowledge < 15.0:
|
||||
knowledge_priority = 1.25
|
||||
|
||||
if DEBUG_LOGS:
|
||||
if debug_logs:
|
||||
print(get_priority_summary())
|
||||
|
||||
|
||||
func apply_resource_delta(resource_id: StringName, amount: float) -> void:
|
||||
match resource_id:
|
||||
&"food":
|
||||
food += amount
|
||||
&"wood":
|
||||
wood += amount
|
||||
&"safety":
|
||||
safety = clamp(safety + amount, 0.0, 100.0)
|
||||
&"knowledge":
|
||||
knowledge += amount
|
||||
|
||||
food = max(food, 0.0)
|
||||
wood = max(wood, 0.0)
|
||||
safety = clamp(safety, 0.0, 100.0)
|
||||
knowledge = max(knowledge, 0.0)
|
||||
|
||||
update_modifiers()
|
||||
update_priorities()
|
||||
|
||||
|
||||
func apply_npc_task(npc: SimNPC) -> void:
|
||||
if npc.is_dead:
|
||||
return
|
||||
@@ -83,31 +104,18 @@ func apply_npc_task(npc: SimNPC) -> void:
|
||||
productivity = 0.5
|
||||
|
||||
match npc.current_task:
|
||||
"gather_food":
|
||||
food += 2.0 * productivity
|
||||
"gather_wood":
|
||||
wood += 2.0 * productivity
|
||||
"patrol":
|
||||
SimulationIds.ACTION_PATROL:
|
||||
safety += 1.0 * productivity
|
||||
"study":
|
||||
SimulationIds.ACTION_STUDY:
|
||||
knowledge += 1.0 * productivity
|
||||
"eat":
|
||||
if food > 0:
|
||||
food -= 1.0
|
||||
npc.hunger = max(npc.hunger - 55.0, 0.0)
|
||||
npc.starvation_ticks = 0
|
||||
npc.is_starving = npc.hunger >= 90.0
|
||||
else:
|
||||
npc.hunger = min(npc.hunger + 5.0, 100.0)
|
||||
npc.is_starving = npc.hunger >= 90.0
|
||||
"rest":
|
||||
SimulationIds.ACTION_REST:
|
||||
if npc.is_starving:
|
||||
npc.energy = min(npc.energy + 2.0, 100.0)
|
||||
elif npc.hunger > 75.0:
|
||||
npc.energy = min(npc.energy + 12.0, 100.0)
|
||||
else:
|
||||
npc.energy = min(npc.energy + 35.0, 100.0)
|
||||
"wander":
|
||||
SimulationIds.ACTION_WANDER:
|
||||
safety -= 0.2
|
||||
|
||||
food = max(food, 0.0)
|
||||
@@ -118,9 +126,11 @@ func apply_npc_task(npc: SimNPC) -> void:
|
||||
update_modifiers()
|
||||
update_priorities()
|
||||
|
||||
func get_summary() -> String:
|
||||
|
||||
return "Village | food: %s | wood: %s | safety: %s | knowledge: %s | priorities F/W/S/K: %s/%s/%s/%s" % [
|
||||
func get_summary() -> String:
|
||||
return (
|
||||
"Village | food: %s | wood: %s | safety: %s | knowledge: %s | priorities F/W/S/K: %s/%s/%s/%s"
|
||||
% [
|
||||
round(food),
|
||||
round(wood),
|
||||
round(safety),
|
||||
@@ -130,16 +140,16 @@ func get_summary() -> String:
|
||||
safety_priority,
|
||||
knowledge_priority
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
func get_priority_summary() -> String:
|
||||
return "Priorities | food: %s | wood: %s | safety: %s | knowledge: %s" % [
|
||||
food_priority,
|
||||
wood_priority,
|
||||
safety_priority,
|
||||
knowledge_priority
|
||||
]
|
||||
return (
|
||||
"Priorities | food: %s | wood: %s | safety: %s | knowledge: %s"
|
||||
% [food_priority, wood_priority, safety_priority, knowledge_priority]
|
||||
)
|
||||
|
||||
|
||||
func debug_log(message: String) -> void:
|
||||
if DEBUG_LOGS:
|
||||
if debug_logs:
|
||||
print("[Village] ", message)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class_name SimulationClock
|
||||
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:
|
||||
tick_interval = maxf(interval, 0.0001)
|
||||
|
||||
|
||||
func advance(delta: float) -> int:
|
||||
accumulator += maxf(delta, 0.0)
|
||||
var ticks_due := 0
|
||||
while accumulator >= tick_interval:
|
||||
accumulator -= tick_interval
|
||||
elapsed_ticks += 1
|
||||
ticks_due += 1
|
||||
return ticks_due
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctbje7tpajoq1
|
||||
+943
-47
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
class_name ActionExecutionSystem
|
||||
extends RefCounted
|
||||
|
||||
|
||||
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
|
||||
if npc.task_progress >= npc.task_duration:
|
||||
npc.task_complete = true
|
||||
npc.task_state = SimNPC.TASK_STATE_COMPLETE
|
||||
|
||||
|
||||
func _update_needs(npc: SimNPC, village: SimVillage) -> void:
|
||||
npc.hunger += 0.125 * village.food_modifier
|
||||
match npc.task_state:
|
||||
SimNPC.TASK_STATE_IDLE:
|
||||
npc.energy -= 0.125
|
||||
SimNPC.TASK_STATE_TRAVELING:
|
||||
npc.energy -= 0.5
|
||||
SimNPC.TASK_STATE_WORKING:
|
||||
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.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
|
||||
npc.starvation_ticks = npc.starvation_ticks + 1 if npc.is_starving else 0
|
||||
if npc.starvation_ticks >= npc.starvation_death_threshold:
|
||||
npc.die_from_starvation()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcoht6j0fvva3
|
||||
@@ -0,0 +1,19 @@
|
||||
class_name ActionSelectionResult
|
||||
extends RefCounted
|
||||
|
||||
var action_id: StringName
|
||||
var duration_override := -1.0
|
||||
var reason: String
|
||||
var scores: Dictionary
|
||||
|
||||
|
||||
func _init(
|
||||
selected_action_id: StringName,
|
||||
selected_duration_override: float = -1.0,
|
||||
decision_reason: String = "",
|
||||
decision_scores: Dictionary = {}
|
||||
) -> void:
|
||||
action_id = selected_action_id
|
||||
duration_override = selected_duration_override
|
||||
reason = decision_reason
|
||||
scores = decision_scores.duplicate(true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dy0n3lije1lec
|
||||
@@ -0,0 +1,255 @@
|
||||
class_name ActionSelectionSystem
|
||||
extends RefCounted
|
||||
|
||||
enum SchedulePeriod {
|
||||
WORK,
|
||||
SLEEP,
|
||||
MEAL,
|
||||
DISCRETIONARY
|
||||
}
|
||||
|
||||
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(
|
||||
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, 4.0, "Starving; pantry is empty"
|
||||
)
|
||||
if npc.hunger > 75.0:
|
||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food"
|
||||
)
|
||||
if village.food > 0:
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food"
|
||||
)
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty"
|
||||
)
|
||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
|
||||
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"
|
||||
)
|
||||
|
||||
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, all_npcs: Array) -> ActionSelectionResult:
|
||||
var scores := {
|
||||
SimulationIds.ACTION_GATHER_FOOD:
|
||||
_calculate_score(
|
||||
npc,
|
||||
SimulationIds.ACTION_GATHER_FOOD,
|
||||
village.food_priority,
|
||||
village.food,
|
||||
15.0,
|
||||
30.0,
|
||||
3.0,
|
||||
1.5
|
||||
),
|
||||
SimulationIds.ACTION_GATHER_WOOD:
|
||||
_calculate_score(
|
||||
npc,
|
||||
SimulationIds.ACTION_GATHER_WOOD,
|
||||
village.wood_priority,
|
||||
village.wood,
|
||||
10.0,
|
||||
20.0,
|
||||
2.5,
|
||||
1.0
|
||||
),
|
||||
SimulationIds.ACTION_PATROL:
|
||||
_calculate_score(
|
||||
npc,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
village.safety_priority,
|
||||
village.safety,
|
||||
30.0,
|
||||
50.0,
|
||||
3.0,
|
||||
1.5
|
||||
),
|
||||
SimulationIds.ACTION_STUDY:
|
||||
_calculate_score(
|
||||
npc,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
village.knowledge_priority,
|
||||
village.knowledge,
|
||||
10.0,
|
||||
25.0,
|
||||
1.5,
|
||||
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 [
|
||||
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY
|
||||
]:
|
||||
var score: float = scores[action_id]
|
||||
if score > best_score:
|
||||
best_action = action_id
|
||||
best_score = score
|
||||
return ActionSelectionResult.new(
|
||||
best_action, -1.0, "Highest current work utility", scores
|
||||
)
|
||||
|
||||
|
||||
func _calculate_score(
|
||||
npc: SimNPC,
|
||||
action_id: StringName,
|
||||
base_priority: float,
|
||||
resource_amount: float,
|
||||
critical_threshold: float,
|
||||
low_threshold: float,
|
||||
critical_bonus: float,
|
||||
low_bonus: float
|
||||
) -> float:
|
||||
var score := base_priority
|
||||
if resource_amount < critical_threshold:
|
||||
score += critical_bonus
|
||||
elif resource_amount < low_threshold:
|
||||
score += low_bonus
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
if (
|
||||
definition != null
|
||||
and not definition.preferred_profession_id.is_empty()
|
||||
and npc.profession == definition.preferred_profession_id
|
||||
):
|
||||
score += 2.0
|
||||
if action_id == npc.last_task:
|
||||
score -= 1.5
|
||||
score += npc.random_source.randf_range(0.0, 0.5)
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c550deaitwskn
|
||||
@@ -0,0 +1,93 @@
|
||||
class_name ActionTargetResolver
|
||||
extends RefCounted
|
||||
|
||||
|
||||
func resolve(
|
||||
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
|
||||
) -> Dictionary:
|
||||
var definition := SimulationDefinitions.get_action(npc.current_task)
|
||||
if definition == null:
|
||||
return {}
|
||||
match definition.target_type:
|
||||
SimulationIds.TARGET_RESOURCE:
|
||||
return _resolve_resource(
|
||||
npc, origin, definition, simulation_manager, active_world_adapter
|
||||
)
|
||||
SimulationIds.TARGET_ACTIVITY:
|
||||
return _resolve_activity(npc, origin, simulation_manager, active_world_adapter)
|
||||
SimulationIds.TARGET_FREE:
|
||||
return {
|
||||
"target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id)
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
func _resolve_resource(
|
||||
npc: SimNPC,
|
||||
origin: Vector3,
|
||||
definition: ActionDefinition,
|
||||
simulation_manager: Node,
|
||||
active_world_adapter: Node
|
||||
) -> Dictionary:
|
||||
var best: Dictionary = {}
|
||||
var best_score := INF
|
||||
for candidate in active_world_adapter.get_resource_candidates(definition.resource_action_id):
|
||||
var node_id := StringName(candidate["target_id"])
|
||||
var state: ResourceStateRecord = simulation_manager.get_resource_state(node_id)
|
||||
if state == null or not state.can_npc_use() or not state.is_available_for(npc.id):
|
||||
continue
|
||||
var position: Vector3 = candidate["position"]
|
||||
var score := score_resource_candidate(npc, origin, position, state)
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best = candidate
|
||||
if best.is_empty():
|
||||
return {}
|
||||
var target_id := StringName(best["target_id"])
|
||||
if not simulation_manager.reserve_resource(target_id, npc.id):
|
||||
return {}
|
||||
return best
|
||||
|
||||
|
||||
func _resolve_activity(
|
||||
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
|
||||
) -> Dictionary:
|
||||
if not active_world_adapter.has_method("get_activity_candidates"):
|
||||
return active_world_adapter.get_activity_target(npc.current_task, origin)
|
||||
|
||||
var best: Dictionary = {}
|
||||
var best_distance := INF
|
||||
var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(npc.current_task)
|
||||
for candidate in candidates:
|
||||
var target_id := StringName(candidate["target_id"])
|
||||
var capacity := maxi(int(candidate.get("capacity", 1)), 1)
|
||||
if (
|
||||
simulation_manager.has_method("get_activity_target_claim_count")
|
||||
and simulation_manager.get_activity_target_claim_count(target_id, npc.id) >= capacity
|
||||
):
|
||||
continue
|
||||
var position: Vector3 = candidate["position"]
|
||||
var distance := origin.distance_squared_to(position)
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best = candidate
|
||||
|
||||
if not best.is_empty():
|
||||
return best
|
||||
if not candidates.is_empty():
|
||||
return {}
|
||||
return active_world_adapter.get_activity_target(npc.current_task, origin)
|
||||
|
||||
|
||||
func score_resource_candidate(
|
||||
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
|
||||
) -> float:
|
||||
var distance := origin.distance_to(position)
|
||||
var comfort_overage := maxf(distance - state.get_comfort_distance(), 0.0)
|
||||
var risk_weight := 20.0 + maxf(100.0 - npc.energy, 0.0) * 0.2
|
||||
return (
|
||||
distance
|
||||
+ comfort_overage * 2.5
|
||||
+ state.get_safety_risk() * risk_weight
|
||||
- state.get_discovery_priority()
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cq8srsm762mnx
|
||||
@@ -0,0 +1,29 @@
|
||||
class_name ActionDefinition
|
||||
extends Resource
|
||||
|
||||
@export var action_id: StringName
|
||||
@export var display_name: String
|
||||
@export_range(0.0, 1000.0, 0.1) var default_duration := 1.0
|
||||
@export var preferred_profession_id: StringName
|
||||
@export var target_type: StringName = SimulationIds.TARGET_ACTIVITY
|
||||
@export var resource_action_id: StringName
|
||||
|
||||
|
||||
func validate() -> Array[String]:
|
||||
var errors: Array[String] = []
|
||||
if action_id.is_empty():
|
||||
errors.append("action_id is empty")
|
||||
if display_name.is_empty():
|
||||
errors.append("display_name is empty for '%s'" % action_id)
|
||||
if default_duration <= 0.0:
|
||||
errors.append("default_duration must be positive for '%s'" % action_id)
|
||||
if (
|
||||
target_type
|
||||
not in [
|
||||
SimulationIds.TARGET_RESOURCE, SimulationIds.TARGET_ACTIVITY, SimulationIds.TARGET_FREE
|
||||
]
|
||||
):
|
||||
errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id])
|
||||
if target_type == SimulationIds.TARGET_RESOURCE and resource_action_id.is_empty():
|
||||
errors.append("resource_action_id is required for '%s'" % action_id)
|
||||
return errors
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1utbqqel3pyw
|
||||
@@ -0,0 +1,19 @@
|
||||
class_name ProfessionDefinition
|
||||
extends Resource
|
||||
|
||||
@export var profession_id: StringName
|
||||
@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]:
|
||||
var errors: Array[String] = []
|
||||
if profession_id.is_empty():
|
||||
errors.append("profession_id is empty")
|
||||
if display_name.is_empty():
|
||||
errors.append("display_name is empty for '%s'" % profession_id)
|
||||
if visual_color.a <= 0.0:
|
||||
errors.append("visual_color is transparent for '%s'" % profession_id)
|
||||
return errors
|
||||
@@ -0,0 +1 @@
|
||||
uid://cimqtsrs3huq0
|
||||
@@ -0,0 +1,132 @@
|
||||
class_name SimulationDefinitions
|
||||
extends RefCounted
|
||||
|
||||
const ACTION_PATHS := [
|
||||
"res://simulation/definitions/actions/gather_food.tres",
|
||||
"res://simulation/definitions/actions/gather_wood.tres",
|
||||
"res://simulation/definitions/actions/patrol.tres",
|
||||
"res://simulation/definitions/actions/study.tres",
|
||||
"res://simulation/definitions/actions/eat.tres",
|
||||
"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/sleep.tres",
|
||||
"res://simulation/definitions/actions/deposit_wood.tres"
|
||||
]
|
||||
|
||||
const PROFESSION_PATHS := [
|
||||
"res://simulation/definitions/professions/farmer.tres",
|
||||
"res://simulation/definitions/professions/woodcutter.tres",
|
||||
"res://simulation/definitions/professions/guard.tres",
|
||||
"res://simulation/definitions/professions/scholar.tres",
|
||||
"res://simulation/definitions/professions/wanderer.tres"
|
||||
]
|
||||
|
||||
static var _action_list: Array[ActionDefinition] = []
|
||||
static var _profession_list: Array[ProfessionDefinition] = []
|
||||
static var _action_map: Dictionary = {}
|
||||
static var _profession_map: Dictionary = {}
|
||||
static var _profession_id_list: Array[StringName] = []
|
||||
static var _cache_valid := false
|
||||
|
||||
|
||||
static func _ensure_cache() -> void:
|
||||
if _cache_valid:
|
||||
return
|
||||
_action_list = []
|
||||
_profession_list = []
|
||||
_action_map = {}
|
||||
_profession_map = {}
|
||||
_profession_id_list = []
|
||||
|
||||
for path in ACTION_PATHS:
|
||||
var definition := load(path) as ActionDefinition
|
||||
if definition != null:
|
||||
_action_list.append(definition)
|
||||
_action_map[definition.action_id] = definition
|
||||
|
||||
for path in PROFESSION_PATHS:
|
||||
var definition := load(path) as ProfessionDefinition
|
||||
if definition != null:
|
||||
_profession_list.append(definition)
|
||||
_profession_map[definition.profession_id] = definition
|
||||
_profession_id_list.append(definition.profession_id)
|
||||
|
||||
_cache_valid = true
|
||||
|
||||
|
||||
static func invalidate_cache() -> void:
|
||||
_cache_valid = false
|
||||
|
||||
|
||||
static func get_actions() -> Array[ActionDefinition]:
|
||||
_ensure_cache()
|
||||
return _action_list
|
||||
|
||||
|
||||
static func get_professions() -> Array[ProfessionDefinition]:
|
||||
_ensure_cache()
|
||||
return _profession_list
|
||||
|
||||
|
||||
static func get_action(action_id: StringName) -> ActionDefinition:
|
||||
_ensure_cache()
|
||||
return _action_map.get(action_id)
|
||||
|
||||
|
||||
static func get_profession(profession_id: StringName) -> ProfessionDefinition:
|
||||
_ensure_cache()
|
||||
return _profession_map.get(profession_id)
|
||||
|
||||
|
||||
static func get_profession_ids() -> Array[StringName]:
|
||||
_ensure_cache()
|
||||
return _profession_id_list
|
||||
|
||||
|
||||
static func validate() -> Array[String]:
|
||||
_ensure_cache()
|
||||
var errors: Array[String] = []
|
||||
if _action_list.size() != ACTION_PATHS.size():
|
||||
errors.append("One or more ActionDefinition resources failed to load")
|
||||
if _profession_list.size() != PROFESSION_PATHS.size():
|
||||
errors.append("One or more ProfessionDefinition resources failed to load")
|
||||
var action_ids := {}
|
||||
for definition in _action_list:
|
||||
for error in definition.validate():
|
||||
errors.append("ActionDefinition: " + error)
|
||||
if action_ids.has(definition.action_id):
|
||||
errors.append("Duplicate action_id '%s'" % definition.action_id)
|
||||
action_ids[definition.action_id] = true
|
||||
|
||||
var profession_ids := {}
|
||||
for definition in _profession_list:
|
||||
for error in definition.validate():
|
||||
errors.append("ProfessionDefinition: " + error)
|
||||
if profession_ids.has(definition.profession_id):
|
||||
errors.append("Duplicate profession_id '%s'" % definition.profession_id)
|
||||
profession_ids[definition.profession_id] = true
|
||||
|
||||
for definition in _action_list:
|
||||
if (
|
||||
not definition.preferred_profession_id.is_empty()
|
||||
and not profession_ids.has(definition.preferred_profession_id)
|
||||
):
|
||||
errors.append(
|
||||
(
|
||||
"Action '%s' references unknown profession '%s'"
|
||||
% [definition.action_id, definition.preferred_profession_id]
|
||||
)
|
||||
)
|
||||
if (
|
||||
definition.target_type == SimulationIds.TARGET_RESOURCE
|
||||
and not action_ids.has(definition.resource_action_id)
|
||||
):
|
||||
errors.append(
|
||||
(
|
||||
"Action '%s' references unknown resource action '%s'"
|
||||
% [definition.action_id, definition.resource_action_id]
|
||||
)
|
||||
)
|
||||
return errors
|
||||
@@ -0,0 +1 @@
|
||||
uid://v1sohi6bbuga
|
||||
@@ -0,0 +1,41 @@
|
||||
class_name SimulationIds
|
||||
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"
|
||||
const ACTION_STUDY := &"study"
|
||||
const ACTION_EAT := &"eat"
|
||||
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"
|
||||
const PROFESSION_GUARD := &"guard"
|
||||
const PROFESSION_SCHOLAR := &"scholar"
|
||||
const PROFESSION_WANDERER := &"wanderer"
|
||||
|
||||
const TARGET_RESOURCE := &"resource"
|
||||
const TARGET_ACTIVITY := &"activity"
|
||||
const TARGET_FREE := &"free"
|
||||
|
||||
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 @@
|
||||
uid://brf7tywgsj6v7
|
||||
@@ -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_food"
|
||||
display_name = "Deposit Food"
|
||||
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 = &"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 = &"eat"
|
||||
display_name = "Eat"
|
||||
default_duration = 2.0
|
||||
target_type = &"activity"
|
||||
@@ -0,0 +1,12 @@
|
||||
[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 = &"gather_food"
|
||||
display_name = "Gather Food"
|
||||
default_duration = 5.0
|
||||
preferred_profession_id = &"farmer"
|
||||
target_type = &"resource"
|
||||
resource_action_id = &"gather_food"
|
||||
@@ -0,0 +1,12 @@
|
||||
[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 = &"gather_wood"
|
||||
display_name = "Gather Wood"
|
||||
default_duration = 5.0
|
||||
preferred_profession_id = &"woodcutter"
|
||||
target_type = &"resource"
|
||||
resource_action_id = &"gather_wood"
|
||||
@@ -0,0 +1,11 @@
|
||||
[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 = &"patrol"
|
||||
display_name = "Patrol"
|
||||
default_duration = 6.0
|
||||
preferred_profession_id = &"guard"
|
||||
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 = &"rest"
|
||||
display_name = "Rest"
|
||||
default_duration = 4.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"
|
||||
@@ -0,0 +1,11 @@
|
||||
[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 = &"study"
|
||||
display_name = "Study"
|
||||
default_duration = 6.0
|
||||
preferred_profession_id = &"scholar"
|
||||
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 = &"wander"
|
||||
display_name = "Wander"
|
||||
default_duration = 4.0
|
||||
target_type = &"free"
|
||||
@@ -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 = &"withdraw_food"
|
||||
display_name = "Withdraw Food"
|
||||
default_duration = 1.0
|
||||
target_type = &"activity"
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
profession_id = &"wanderer"
|
||||
display_name = "Wanderer"
|
||||
visual_color = Color(0.72, 0.57, 0.3, 1)
|
||||
prop_color = Color(0.38, 0.28, 0.19, 1)
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_resource type="Resource" script_class="ProfessionDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ProfessionDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
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
|
||||
@@ -0,0 +1,22 @@
|
||||
extends Node
|
||||
|
||||
@export var simulation_manager: Node
|
||||
|
||||
var store := SaveSlotStore.new()
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_F5:
|
||||
if store.save(simulation_manager):
|
||||
print("[SaveSlot] Quicksave written")
|
||||
else:
|
||||
push_error("[SaveSlot] " + store.last_error)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.keycode == KEY_F9:
|
||||
if store.load_into(simulation_manager):
|
||||
print("[SaveSlot] Quicksave restored")
|
||||
else:
|
||||
push_error("[SaveSlot] " + store.last_error)
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwvdljsh148wh
|
||||
@@ -0,0 +1,139 @@
|
||||
class_name SaveSlotStore
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_DIRECTORY := "user://saves"
|
||||
const DEFAULT_SLOT := "quicksave"
|
||||
const MAX_SAVE_BYTES := 16 * 1024 * 1024
|
||||
|
||||
var save_directory: String
|
||||
var last_error := ""
|
||||
|
||||
|
||||
func _init(directory: String = DEFAULT_DIRECTORY) -> void:
|
||||
save_directory = directory
|
||||
|
||||
|
||||
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
last_error = ""
|
||||
if manager == null or not manager.has_method("serialize_state"):
|
||||
return _fail("Save source cannot serialize simulation state")
|
||||
if not _is_valid_slot_name(slot_name):
|
||||
return _fail("Invalid save slot name")
|
||||
|
||||
var json_text: String = manager.serialize_state()
|
||||
if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES:
|
||||
return _fail("Save exceeds the supported size limit")
|
||||
if SimulationStateRecord.from_json(json_text) == null:
|
||||
return _fail("Simulation produced an invalid save record")
|
||||
if not _ensure_directory():
|
||||
return false
|
||||
|
||||
var final_path := get_slot_path(slot_name)
|
||||
var temporary_path := final_path + ".tmp"
|
||||
var backup_path := final_path + ".bak"
|
||||
_remove_if_present(temporary_path)
|
||||
|
||||
var file := FileAccess.open(temporary_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return _fail("Could not open temporary save file")
|
||||
file.store_string(json_text)
|
||||
file.flush()
|
||||
file.close()
|
||||
if _read_record(temporary_path) == null:
|
||||
_remove_if_present(temporary_path)
|
||||
return _fail("Temporary save failed validation")
|
||||
|
||||
var had_existing := FileAccess.file_exists(final_path)
|
||||
if had_existing:
|
||||
_remove_if_present(backup_path)
|
||||
if DirAccess.rename_absolute(final_path, backup_path) != OK:
|
||||
_remove_if_present(temporary_path)
|
||||
return _fail("Could not preserve the previous save")
|
||||
if DirAccess.rename_absolute(temporary_path, final_path) != OK:
|
||||
if had_existing:
|
||||
DirAccess.rename_absolute(backup_path, final_path)
|
||||
_remove_if_present(temporary_path)
|
||||
return _fail("Could not install the new save")
|
||||
_remove_if_present(backup_path)
|
||||
return true
|
||||
|
||||
|
||||
func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
last_error = ""
|
||||
if manager == null or not manager.has_method("restore_state"):
|
||||
return _fail("Save target cannot restore simulation state")
|
||||
if not _is_valid_slot_name(slot_name):
|
||||
return _fail("Invalid save slot name")
|
||||
|
||||
var final_path := get_slot_path(slot_name)
|
||||
var record := _read_record(final_path)
|
||||
if record == null:
|
||||
record = _read_record(final_path + ".bak")
|
||||
if record == null:
|
||||
return _fail("Save slot is missing, invalid, or unsupported")
|
||||
if not bool(manager.restore_state(record)):
|
||||
return _fail("Simulation refused the validated save record")
|
||||
return true
|
||||
|
||||
|
||||
func has_slot(slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
if not _is_valid_slot_name(slot_name):
|
||||
return false
|
||||
var final_path := get_slot_path(slot_name)
|
||||
return _read_record(final_path) != null or _read_record(final_path + ".bak") != null
|
||||
|
||||
|
||||
func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool:
|
||||
if not _is_valid_slot_name(slot_name):
|
||||
return false
|
||||
var final_path := get_slot_path(slot_name)
|
||||
_remove_if_present(final_path)
|
||||
_remove_if_present(final_path + ".tmp")
|
||||
_remove_if_present(final_path + ".bak")
|
||||
return true
|
||||
|
||||
|
||||
func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String:
|
||||
return save_directory.path_join(slot_name + ".json")
|
||||
|
||||
|
||||
func _read_record(path: String) -> SimulationStateRecord:
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return null
|
||||
if file.get_length() > MAX_SAVE_BYTES:
|
||||
file.close()
|
||||
return null
|
||||
var json_text := file.get_as_text()
|
||||
file.close()
|
||||
return SimulationStateRecord.from_json(json_text)
|
||||
|
||||
|
||||
func _ensure_directory() -> bool:
|
||||
var absolute_directory := ProjectSettings.globalize_path(save_directory)
|
||||
var error := DirAccess.make_dir_recursive_absolute(absolute_directory)
|
||||
if error != OK and error != ERR_ALREADY_EXISTS:
|
||||
return _fail("Could not create the save directory")
|
||||
return true
|
||||
|
||||
|
||||
func _is_valid_slot_name(slot_name: String) -> bool:
|
||||
if slot_name.is_empty() or slot_name.length() > 32:
|
||||
return false
|
||||
var allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
||||
for character in slot_name:
|
||||
if not allowed.contains(character):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _remove_if_present(path: String) -> void:
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://3w3wsbtr4gpw
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user