feat: introduce identity-backed goat
This commit is contained in:
@@ -16,6 +16,7 @@ SimulationClock
|
||||
-> ActionSelectionSystem chooses an action
|
||||
-> ActionTargetResolver resolves a stable target ID
|
||||
-> VillageEconomy performs inventory/storage transactions
|
||||
-> AnimalCareSystem advances animal needs and composes feeding
|
||||
-> SimulationEventLog records completed facts
|
||||
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
||||
-> RelationshipSystem applies evidence-gated social consequences
|
||||
@@ -35,6 +36,9 @@ would otherwise obscure that lifecycle:
|
||||
- `simulation/actions/` owns selection, progress, and target resolution;
|
||||
- `simulation/economy/VillageEconomy.gd` owns storage/inventory transactions
|
||||
and keeps village resource summaries synchronized;
|
||||
- `simulation/animals/AnimalCareSystem.gd` owns animal records, hunger
|
||||
advancement, loaded binding, feed reservations, and the exact conserved
|
||||
pantry-to-animal operation used by NPCs and the player;
|
||||
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||
history queries, and rate calculations;
|
||||
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
||||
@@ -89,6 +93,7 @@ hard to read.
|
||||
| --- | --- |
|
||||
| `simulation/` | Headless-capable orchestration and core models |
|
||||
| `simulation/actions/` | Action decisions, execution, and target queries |
|
||||
| `simulation/animals/` | Animal lifecycle, target claims, and feeding transactions |
|
||||
| `simulation/economy/` | Authoritative inventory and storage transactions |
|
||||
| `simulation/events/` | Immutable event history and derived event queries |
|
||||
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
|
||||
@@ -100,6 +105,7 @@ hard to read.
|
||||
| `simulation/persistence/` | Validated local save-file storage |
|
||||
| `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads |
|
||||
| `world/` | Loaded-world interaction geometry and presentation adapters |
|
||||
| `world/animals/` | Animal presentation binding and interaction geometry |
|
||||
| `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index |
|
||||
| `world/storage/` | Storage interaction geometry, never stored quantities |
|
||||
| `world/activity/` | Rest/study/patrol interaction sites and capacity facts |
|
||||
@@ -130,6 +136,10 @@ improving ownership.
|
||||
reactions are not replayed.
|
||||
- Resource changes go through `ResourceStateRecord`, NPC inventory, and
|
||||
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
|
||||
- Animal identity, position, hunger, last feed tick, and reservation live in
|
||||
`AnimalStateRecord`. `AnimalNode` binds that state and contributes only its
|
||||
loaded interaction point. Animal candidates are currently scanned linearly;
|
||||
they do not enter the resource grid.
|
||||
- New mutable features define serialization and deterministic continuation at
|
||||
the same time as their first gameplay use. Cross-record causes use stable
|
||||
event IDs rather than object references or prose.
|
||||
|
||||
@@ -798,15 +798,22 @@ Completed:
|
||||
stone instances stay presentation-only. Both finite trees remain reachable,
|
||||
deplete into shared stumps, restore from authoritative amount, and keep the
|
||||
world/save total at eighteen resources.
|
||||
40. `Jajce Goat 07`: Dunja is the first identity-backed animal, with a stable
|
||||
goat ID, simulation-owned position and hunger, and a loaded cozy
|
||||
presentation with an honest hungry cue. NPC and player feeding share one
|
||||
exact transaction that withdraws pantry food, changes only Dunja's state,
|
||||
and emits one economic fact. World-schema v10 save/restore preserves the
|
||||
result without replaying the transient feed response.
|
||||
|
||||
Next:
|
||||
|
||||
1. Add one named goat as an identity-backed simulation vertical slice: stable
|
||||
animal ID, authoritative position and hunger/feed state, loaded visual
|
||||
binding, and an exact feed interaction shared by one NPC and the player.
|
||||
Feeding must withdraw real pantry food and survive deterministic save/load.
|
||||
2. Add a small herd, breeding, or animal products only after that single-animal
|
||||
identity and conservation contract is proven.
|
||||
1. Give Dunja one deterministic pasture/shelter routine using
|
||||
simulation-owned destination and position, real loaded-world navigation,
|
||||
and exact mid-move save/restore continuation.
|
||||
2. Add a second goat only after movement proves that identity remains stable
|
||||
across care, navigation, unloading, and restore.
|
||||
3. Keep breeding, products, herd scheduling, animal spatial indexing, and
|
||||
active/abstract simulation LOD deferred until two real animals require them.
|
||||
|
||||
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
|
||||
|
||||
+19
-7
@@ -10,6 +10,7 @@ resource_extracted
|
||||
storage_deposited
|
||||
storage_withdrawn
|
||||
item_consumed
|
||||
animal_fed
|
||||
```
|
||||
|
||||
Each record contains a monotonically increasing event ID, event type,
|
||||
@@ -17,8 +18,10 @@ simulation tick, actor ID, source ID, destination ID, item ID, and transferred
|
||||
amount, plus the authoritative world position captured when the fact is
|
||||
recorded. NPC events preserve their authoritative interaction target (or NPC
|
||||
position when no target applies); player extraction/depletion events preserve
|
||||
the ResourceNode interaction position. Actor `-1` identifies a player-triggered
|
||||
extraction until persistent player identity is introduced.
|
||||
the ResourceNode interaction position. `animal_fed` records one food moving
|
||||
from `village_pantry` to a stable animal ID at the animal's authoritative
|
||||
position. Actor `-1` identifies a player-triggered extraction or feeding 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,
|
||||
@@ -26,8 +29,10 @@ inventory, and storage records remain authoritative.
|
||||
|
||||
`SimulationEventLog` owns ordered event identity, append/restore behavior, and
|
||||
history/rate queries, including exact lookup through `get_by_id()`.
|
||||
`VillageEconomy` performs transactions and requests event records only after
|
||||
state changes succeed; `SimulationManager` remains the public signal boundary
|
||||
`VillageEconomy` performs inventory and storage transactions and requests event
|
||||
records only after state changes succeed. `AnimalCareSystem` composes the
|
||||
pantry withdrawal with animal hunger relief and requests `animal_fed` only
|
||||
after both succeed. `SimulationManager` remains the scene-tree signal boundary
|
||||
used by presentation.
|
||||
|
||||
An action whose definition-backed completion cost becomes unavailable records
|
||||
@@ -38,17 +43,24 @@ transfer occurred.
|
||||
|
||||
## Persistence and determinism
|
||||
|
||||
`SimulationStateRecord` schema v9 stores the ordered event stream,
|
||||
`SimulationStateRecord` schema v10 stores the ordered event stream,
|
||||
`next_event_id`, directed relationships that may reference an exact event, and
|
||||
per-NPC known-event references with first-acquisition provenance, plus
|
||||
opportunity records that reference exact trigger/resolution events. Schema v1
|
||||
and v2 saves migrate to an empty stream beginning at ID zero; world schemas
|
||||
v1–v7 migrate to an empty opportunity list, while world schema v8 preserves and
|
||||
normalizes its pantry opportunity history. Parsing rejects duplicate event
|
||||
v1–v7 migrate to an empty opportunity list, while world schemas v8 and v9
|
||||
preserve and normalize opportunity history. World v9 adds an empty animal list
|
||||
during migration. Parsing rejects duplicate event
|
||||
IDs, invalid or duplicate knowledge/opportunity references, impossible
|
||||
communicator sources, relationship causes the observer does not know, and next
|
||||
IDs that could collide with restored history.
|
||||
|
||||
Animal-feed facts receive an additional cross-record check: actor is either an
|
||||
existing NPC or the player sentinel, source is the real pantry, destination is
|
||||
an existing animal, item and definition cost are exactly one food, event tick
|
||||
is not in the future, and the animal's `last_fed_tick` agrees with its latest
|
||||
feed fact.
|
||||
|
||||
Positive food deposits, positive NPC food withdrawals from `village_pantry`
|
||||
into that actor's matching inventory, and definition-backed patrol/study
|
||||
`task_blocked` facts caused by missing wood at `village_woodpile` are currently
|
||||
|
||||
@@ -923,17 +923,27 @@ rebuilds the sparse standing tree from amount alone. The river and forest
|
||||
clusters now share only the terrain-snap, bounded-instance, and anchor-layout
|
||||
code proven by those two consumers.
|
||||
|
||||
The immediate next slice should introduce livestock only through one
|
||||
identity-backed animal vertical slice: one named goat with a stable animal ID,
|
||||
simulation-owned position and hunger/feed state, a loaded presentation binding,
|
||||
and one exact feed interaction available to both an NPC and the player. Feeding
|
||||
must consume real pantry food and save/restore deterministically. Do not make
|
||||
the animal a `ResourceNode`, add a herd, breeding, products, or a generalized
|
||||
animal spatial index before that first identity and conservation contract is
|
||||
honest.
|
||||
The first identity-backed livestock slice is complete. Dunja has a stable
|
||||
animal ID, simulation-owned position and hunger/feed state, and a loaded cozy
|
||||
presentation whose persistent cue derives from that state. One exact operation
|
||||
serves both NPC and player feeding, withdraws real pantry food, mutates only the
|
||||
target animal, and appends one economic fact. World-schema v10 save/restore
|
||||
preserves the result without replaying the transient response.
|
||||
|
||||
The immediate next slice should give Dunja one deterministic
|
||||
pasture/shelter routine. Her destination and position must remain
|
||||
simulation-owned, loaded movement must use real navigation, and a mid-move
|
||||
save/restore must continue exactly without consuming new decision RNG. Keep
|
||||
loaded-animal discovery linear and do not add a second goat, herd scheduling,
|
||||
breeding, products, or active/abstract simulation LOD until this movement
|
||||
contract is honest.
|
||||
|
||||
Recently completed:
|
||||
|
||||
- `Jajce Goat 07`: named goat Dunja proves stable animal identity, exact
|
||||
pantry-conserving NPC/player feeding, deterministic hunger and save/restore,
|
||||
loaded target reservation, a state-derived hungry cue, and a transient cozy
|
||||
response that never enters persisted state.
|
||||
- `Jajce Forest Edge 06`: two existing deep-forest tree IDs now anchor a
|
||||
restrained four-tree silhouette with 24 decorative understory instances.
|
||||
Both targets require expanded loaded-resource discovery, remain reachable,
|
||||
|
||||
+11
-8
@@ -616,10 +616,10 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
||||
- 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, knowledge, relationship,
|
||||
opportunity, clock, and RNG state serialize through world schema v9. F5/F9
|
||||
provide one validated local quicksave; a save menu, metadata, and
|
||||
player-transform persistence remain deferred.
|
||||
- Current NPC, village, resource, storage, animal, event, knowledge,
|
||||
relationship, opportunity, clock, and RNG state serialize through world
|
||||
schema v10. 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
|
||||
@@ -987,10 +987,13 @@ visuals, and two bounded foliage/resource placement proofs are complete. The
|
||||
riverbank and forest edge preserve stable finite-resource IDs, separate
|
||||
decorative density from simulation authority, support discovery beyond the
|
||||
first 24 m range, and reconstruct sparse/depleted visuals from authoritative
|
||||
amount. The next slice should introduce one named goat only through a stable
|
||||
simulation identity, authoritative position and feed state, loaded visual
|
||||
binding, exact NPC/player feeding cost, and deterministic save/restore. Do not
|
||||
yet generalize the resource grid or introduce active/abstract simulation LOD.
|
||||
amount. Dunja now proves the first identity-backed animal contract: stable
|
||||
animal identity, simulation-owned position and hunger, a loaded visual binding,
|
||||
and the same exact pantry-conserving feed operation for NPC and player.
|
||||
World-schema v10 restore reconstructs her persistent state without replaying
|
||||
the transient response. The next bounded animal slice should give Dunja one
|
||||
deterministic pasture/shelter routine before adding another goat. Do not yet
|
||||
generalize animal discovery or introduce active/abstract simulation LOD.
|
||||
|
||||
The remaining simulation-garden target still aims for:
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ Each executable action definition contains:
|
||||
- display name;
|
||||
- default duration;
|
||||
- optional preferred profession ID;
|
||||
- target type: resource, activity, or free movement;
|
||||
- target type: resource, activity, animal, or free movement;
|
||||
- resource action ID when the action targets a ResourceNode;
|
||||
- optional completion-cost resource ID and amount.
|
||||
|
||||
The current actions are gather food, gather wood, deposit food, deposit wood,
|
||||
withdraw food, patrol, study, eat, rest, sleep, and wander. Idle and dead are
|
||||
stable state sentinels, not executable action definitions.
|
||||
The current actions are gather food, gather wood, feed animal, deposit food,
|
||||
deposit wood, withdraw food, patrol, study, eat, rest, sleep, 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
|
||||
@@ -33,6 +33,11 @@ instead of maintaining a separate gather-action map. ActionSelectionSystem and
|
||||
SimulationManager share the same completion-cost metadata, so patrol and study
|
||||
both require one stored wood without duplicating that rule.
|
||||
|
||||
`feed_animal` is the first animal-targeted action. Its definition prefers the
|
||||
farmer profession and declares an exact one-food completion cost. Selection,
|
||||
animal target reservation, and completion remain separate; NPC and player
|
||||
completion both use `AnimalCareSystem`'s same pantry-to-animal transaction.
|
||||
|
||||
## Current profession contract
|
||||
|
||||
Each profession definition contains a stable `profession_id`, display name,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current contract
|
||||
|
||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||
simulation. The current world schema is v9 and captures:
|
||||
simulation. The current world schema is v10 and captures:
|
||||
|
||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||
clock ticks;
|
||||
@@ -13,6 +13,8 @@ simulation. The current world schema is v9 and captures:
|
||||
- controlled per-NPC wander RNG streams;
|
||||
- scene-independent resource state plus the definition facts needed while its
|
||||
ResourceNode is unloaded;
|
||||
- scene-independent animal identity, position, hunger, feeding history,
|
||||
reservation, and availability state;
|
||||
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
||||
and its next stable event ID;
|
||||
- directed relationship records with familiarity, trust, and the stable event
|
||||
@@ -29,7 +31,7 @@ The top-level identity is:
|
||||
```json
|
||||
{
|
||||
"schema": "the_steward.simulation",
|
||||
"schema_version": 9
|
||||
"schema_version": 10
|
||||
}
|
||||
```
|
||||
|
||||
@@ -170,6 +172,21 @@ nested v1/v2 events receive empty/zero defaults, while a new `task_blocked`
|
||||
event requires its stable action, source storage, resource, and positive
|
||||
requirement contract.
|
||||
|
||||
SimulationStateRecord v10 adds the top-level `animals` array and the first
|
||||
`AnimalStateRecord` schema. Each animal record stores its stable animal and
|
||||
species IDs, display name, authoritative world position, hunger,
|
||||
`last_fed_tick`, reservation owner, and enabled/player/NPC feeding flags.
|
||||
World schema v9 preserves opportunity history while migrating to an empty
|
||||
animal list.
|
||||
|
||||
Current-schema parsing requires unique animal IDs that do not collide with
|
||||
resource or storage IDs. An animal reservation must belong to an existing NPC
|
||||
whose active feed task targets that exact animal. Every `animal_fed` event
|
||||
must name the pantry as its source, an existing animal as its destination,
|
||||
food as its item, the exact feed action cost, and a valid NPC actor or the
|
||||
player sentinel. The latest matching feed event tick must agree with the
|
||||
animal's `last_fed_tick`.
|
||||
|
||||
The bounded opportunity family accepts at most one open record globally.
|
||||
Opportunity, trigger-event, and resolution-event IDs are unique and the next
|
||||
ID must remain above restored history. Referenced NPCs, storage, resources, and
|
||||
@@ -235,6 +252,24 @@ explicitly. Current-schema parsing also canonicalizes numeric, boolean, and ID
|
||||
variants so a freshly registered finite source retains a byte-stable checksum
|
||||
after a JSON round trip.
|
||||
|
||||
## Animal authority
|
||||
|
||||
`AnimalCareSystem` owns `AnimalStateRecord` instances independently of the
|
||||
scene tree and advances their hunger in stable-ID order. `AnimalNode` binds a
|
||||
loaded presentation and interaction point to that authority; the simulation
|
||||
position remains canonical while the node is loaded or absent.
|
||||
|
||||
Animals are not `ResourceNode` instances and are not inserted into the
|
||||
resource discovery grid. The first bounded slice uses a deterministic linear
|
||||
loaded-animal query. NPC and player feeding resolve through the same atomic
|
||||
care operation: reserve or select the exact animal, withdraw one real food
|
||||
from `village_pantry`, reduce that animal's hunger, and append one exact
|
||||
`animal_fed` fact. Failed withdrawal changes neither animal nor history.
|
||||
|
||||
The persistent hungry/content presentation derives from authoritative hunger.
|
||||
The short feed response is transient and is reset rather than serialized or
|
||||
replayed after restore.
|
||||
|
||||
## Local quicksave boundary
|
||||
|
||||
`SaveSlotStore` writes the existing versioned JSON record to
|
||||
@@ -253,7 +288,7 @@ This phase does not yet provide:
|
||||
|
||||
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||
- migrations from any historical world schema other than the explicitly
|
||||
supported v1–v8 layouts;
|
||||
supported v1–v9 layouts;
|
||||
- player inventory or player relationship records;
|
||||
- broader relationship dimensions, line-of-sight/hearing evidence,
|
||||
continuous or personalized memory decay, reinforcement, false beliefs, or
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Jajce Goat 07
|
||||
|
||||
Captured 2026-07-26 with the Metal renderer from the same close-up camera:
|
||||
|
||||
- [hungry frame](jajce_goat_07_hungry.png)
|
||||
- [content frame](jajce_goat_07_content.png)
|
||||
|
||||
Only authoritative hunger changes between the paired frames. Dunja lowers her
|
||||
head toward the bowl and shows a small amber empty-bowl cue while hungry; after
|
||||
feeding she raises her head and the persistent cue clears. The brief green
|
||||
response is deliberately transient and is not part of the restored frame.
|
||||
|
||||
This visual comparison sits on the gameplay proof:
|
||||
|
||||
- Dunja has one stable animal ID and is not a resource node;
|
||||
- NPC and player use the same exact feed operation;
|
||||
- one feed withdraws one real pantry food and relieves only Dunja;
|
||||
- world-schema v10 restores position, hunger, and last-fed tick without
|
||||
replaying presentation effects.
|
||||
|
||||
The next animal frame should prove a real pasture/shelter movement routine,
|
||||
not multiply static livestock.
|
||||
|
||||
Capture command:
|
||||
|
||||
```bash
|
||||
HOME="$PWD/logs/quality/godot_profile" \
|
||||
APPDATA="$PWD/logs/quality/godot_profile" \
|
||||
LOCALAPPDATA="$PWD/logs/quality/godot_profile" \
|
||||
/Applications/Godot.app/Contents/MacOS/Godot \
|
||||
--path "$PWD" --script res://tools/capture_jajce_lookdev.gd -- \
|
||||
--scene=res://world/jajce/JajceGoatLookdev.tscn \
|
||||
--hide-debug-labels --animal-hunger=goat_dunja:90 \
|
||||
--output=res://docs/baselines/jajce_goat_07_hungry.png
|
||||
```
|
||||
|
||||
For the content frame, change the hunger override to `20` and the output
|
||||
filename to `jajce_goat_07_content.png`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://0ldryrvw76q6"
|
||||
path="res://.godot/imported/jajce_goat_07_content.png-138802d07281bb13d9d6edbbb359f838.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://docs/baselines/jajce_goat_07_content.png"
|
||||
dest_files=["res://.godot/imported/jajce_goat_07_content.png-138802d07281bb13d9d6edbbb359f838.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bexlabef1e1xa"
|
||||
path="res://.godot/imported/jajce_goat_07_hungry.png-2474d252bb9b213aa9e6cc2457baacb4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://docs/baselines/jajce_goat_07_hungry.png"
|
||||
dest_files=["res://.godot/imported/jajce_goat_07_hungry.png-2474d252bb9b213aa9e6cc2457baacb4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -478,6 +478,11 @@ func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
|
||||
food.radius = 0.18
|
||||
food.height = 0.26
|
||||
return food
|
||||
SimulationIds.ACTION_FEED_ANIMAL:
|
||||
var feed := SphereMesh.new()
|
||||
feed.radius = 0.18
|
||||
feed.height = 0.26
|
||||
return feed
|
||||
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||
var wood := BoxMesh.new()
|
||||
wood.size = Vector3(0.18, 0.42, 0.18)
|
||||
@@ -521,6 +526,8 @@ 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_FEED_ANIMAL:
|
||||
return Color(0.95, 0.64, 0.25, 1.0)
|
||||
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||
return Color(0.58, 0.34, 0.16, 1.0)
|
||||
SimulationIds.ACTION_DEPOSIT_FOOD:
|
||||
|
||||
@@ -59,6 +59,9 @@ func try_interact() -> void:
|
||||
if simulation_manager == null:
|
||||
return
|
||||
|
||||
if try_feed_animal():
|
||||
return
|
||||
|
||||
if try_harvest_resource_node():
|
||||
return
|
||||
|
||||
@@ -73,6 +76,25 @@ func try_interact() -> void:
|
||||
print("Player ate food from the village supply.")
|
||||
|
||||
|
||||
func try_feed_animal() -> bool:
|
||||
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
|
||||
push_error("Player: SimulationManager has no animal-care service")
|
||||
return false
|
||||
var node: AnimalNode = simulation_manager.animal_care.find_node_for_player(
|
||||
global_position, interaction_range
|
||||
)
|
||||
if node == null:
|
||||
return false
|
||||
if not simulation_manager.has_method("feed_animal"):
|
||||
push_error("Player: SimulationManager cannot feed AnimalNodes")
|
||||
return true
|
||||
if simulation_manager.feed_animal(node.animal_id, -1):
|
||||
print("Player fed %s one food from the village pantry." % node.display_name)
|
||||
else:
|
||||
print("%s is hungry, but the pantry has no food to spare." % node.display_name)
|
||||
return true
|
||||
|
||||
|
||||
func try_harvest_resource_node() -> bool:
|
||||
if not simulation_manager.has_method("find_resource_node_for_player"):
|
||||
push_error("Player: SimulationManager cannot find ResourceNodes")
|
||||
|
||||
@@ -2,6 +2,7 @@ extends Node
|
||||
|
||||
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||
const AnimalCareSystemScript := preload("res://simulation/animals/AnimalCareSystem.gd")
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||
|
||||
@@ -38,6 +39,7 @@ var wander_random_sources := {}
|
||||
var resource_states: Dictionary = {}
|
||||
var event_log := SimulationEventLogScript.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
var animal_care := AnimalCareSystemScript.new()
|
||||
var relationship_system := RelationshipSystemScript.new()
|
||||
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
||||
var opportunity_system := VillageOpportunitySystem.new()
|
||||
@@ -71,6 +73,8 @@ func _ready() -> void:
|
||||
economy.inventory_changed.connect(_on_economy_inventory_changed)
|
||||
economy.economic_event_requested.connect(_record_economic_event)
|
||||
economy.narrative_event_requested.connect(record_narrative_event)
|
||||
animal_care.economic_event_requested.connect(_record_economic_event_at)
|
||||
animal_care.narrative_event_requested.connect(record_narrative_event)
|
||||
action_selector.relationship_system = relationship_system
|
||||
var definition_errors := SimulationDefinitions.validate()
|
||||
if not definition_errors.is_empty():
|
||||
@@ -83,12 +87,14 @@ func _ready() -> void:
|
||||
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
||||
village.debug_logs = debug_logs
|
||||
economy.configure(village, debug_logs)
|
||||
animal_care.configure(economy, active_world_adapter)
|
||||
economy.initialize_storage()
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
generate_npcs()
|
||||
relationship_system.initialize_households(npcs)
|
||||
call_deferred("register_loaded_resource_nodes")
|
||||
animal_care.call_deferred("register_loaded_nodes")
|
||||
call_deferred("register_loaded_storage_nodes")
|
||||
if debug_logs:
|
||||
print("--- Simulation started ---")
|
||||
@@ -156,6 +162,7 @@ func simulate_tick() -> void:
|
||||
tick_count += 1
|
||||
if debug_logs:
|
||||
print("--- Tick ", tick_count, " ---")
|
||||
animal_care.advance()
|
||||
var village_was_changed := false
|
||||
_population_view.rebuild(npcs)
|
||||
for npc in npcs:
|
||||
@@ -210,8 +217,9 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
|
||||
if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]:
|
||||
return
|
||||
var helper := get_active_opportunity_helper()
|
||||
var animal_feed_name := animal_care.get_available_feed_name(npc.id)
|
||||
var selection := action_selector.select_action(
|
||||
npc, village, clock.time_of_day(), npcs, helper, _population_view
|
||||
npc, village, clock.time_of_day(), npcs, helper, _population_view, animal_feed_name
|
||||
)
|
||||
if selection == null:
|
||||
return
|
||||
@@ -239,7 +247,12 @@ func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target:
|
||||
func _complete_current_action(npc: SimNPC) -> void:
|
||||
var completed_task := npc.current_task
|
||||
var definition := SimulationDefinitions.get_action(completed_task)
|
||||
if economy.consume_completion_cost(npc, definition):
|
||||
var completion_succeeded := false
|
||||
if completed_task == SimulationIds.ACTION_FEED_ANIMAL:
|
||||
completion_succeeded = feed_animal(npc.target_id, npc.id)
|
||||
else:
|
||||
completion_succeeded = economy.consume_completion_cost(npc, definition)
|
||||
if completion_succeeded:
|
||||
_apply_action_completion(npc, completed_task, definition)
|
||||
elif not npc.target_id.is_empty():
|
||||
release_npc_reservation(npc.id)
|
||||
@@ -272,6 +285,8 @@ func _apply_action_completion(
|
||||
npc.energy = minf(npc.energy + 40.0, 100.0)
|
||||
npc.position = npc.home_position
|
||||
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
|
||||
SimulationIds.ACTION_FEED_ANIMAL:
|
||||
pass
|
||||
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
|
||||
village.apply_npc_task(npc)
|
||||
if debug_logs and completed_task == SimulationIds.ACTION_SLEEP:
|
||||
@@ -364,6 +379,9 @@ func release_npc_reservation(npc_id: int) -> void:
|
||||
var resource_state := get_resource_state(npc.target_id)
|
||||
if resource_state != null:
|
||||
resource_state.release(npc.id)
|
||||
var animal_state := animal_care.get_state(npc.target_id)
|
||||
if animal_state != null:
|
||||
animal_state.release(npc.id)
|
||||
npc.target_id = &""
|
||||
return
|
||||
|
||||
@@ -397,6 +415,22 @@ func notify_npc_arrived(npc_id: int) -> void:
|
||||
)
|
||||
notify_npc_navigation_failed(npc.id)
|
||||
return
|
||||
if (
|
||||
npc.target_id != &""
|
||||
and definition != null
|
||||
and definition.target_type == SimulationIds.TARGET_ANIMAL
|
||||
):
|
||||
if not animal_care.can_complete_feed(npc.target_id, npc.id):
|
||||
if debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
npc.npc_name,
|
||||
" arrived but animal ",
|
||||
npc.target_id,
|
||||
" no longer needs feeding, replanning"
|
||||
)
|
||||
notify_npc_navigation_failed(npc.id)
|
||||
return
|
||||
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
@@ -903,6 +937,13 @@ func _on_economy_inventory_changed(npc: SimNPC, item_id: StringName, amount: flo
|
||||
npc_inventory_changed.emit(npc, item_id, amount)
|
||||
|
||||
|
||||
func feed_animal(animal_id: StringName, actor_id: int = -1) -> bool:
|
||||
var succeeded := animal_care.feed(animal_id, actor_id, npcs, tick_count)
|
||||
if succeeded:
|
||||
village_changed.emit(village)
|
||||
return succeeded
|
||||
|
||||
|
||||
func register_loaded_resource_nodes() -> void:
|
||||
for node in ResourceNode.get_all():
|
||||
register_resource_node(node)
|
||||
@@ -1051,50 +1092,6 @@ func get_starving_count() -> int:
|
||||
return _population_view.starving_count()
|
||||
|
||||
|
||||
func get_state_snapshot() -> Dictionary:
|
||||
var npc_snapshots: Array[Dictionary] = []
|
||||
for npc in npcs:
|
||||
npc_snapshots.append(
|
||||
{
|
||||
"id": npc.id,
|
||||
"name": npc.npc_name,
|
||||
"profession": npc.profession,
|
||||
"hunger": npc.hunger,
|
||||
"energy": npc.energy,
|
||||
"strength": npc.strength,
|
||||
"intelligence": npc.intelligence,
|
||||
"task": npc.current_task,
|
||||
"task_state": npc.task_state,
|
||||
"task_duration": npc.task_duration,
|
||||
"task_progress": npc.task_progress,
|
||||
"target_id": String(npc.target_id),
|
||||
"position": [npc.position.x, npc.position.y, npc.position.z],
|
||||
"travel_target_position":
|
||||
[
|
||||
npc.travel_target_position.x,
|
||||
npc.travel_target_position.y,
|
||||
npc.travel_target_position.z
|
||||
],
|
||||
"has_travel_target": npc.has_travel_target,
|
||||
"starvation_ticks": npc.starvation_ticks,
|
||||
"is_dead": npc.is_dead
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"seed": simulation_seed,
|
||||
"tick_count": tick_count,
|
||||
"village":
|
||||
{
|
||||
"food": village.food,
|
||||
"wood": village.wood,
|
||||
"safety": village.safety,
|
||||
"knowledge": village.knowledge
|
||||
},
|
||||
"npcs": npc_snapshots
|
||||
}
|
||||
|
||||
|
||||
func get_state_checksum() -> String:
|
||||
return create_state_record().to_json().sha256_text()
|
||||
|
||||
@@ -1127,6 +1124,7 @@ func create_state_record() -> SimulationStateRecord:
|
||||
record.village = VillageStateRecord.capture(village)
|
||||
for npc in npcs:
|
||||
record.npcs.append(NPCStateRecord.capture(npc))
|
||||
animal_care.append_state_records(record)
|
||||
var sorted_resource_ids: Array = resource_states.keys()
|
||||
sorted_resource_ids.sort()
|
||||
for resource_id in sorted_resource_ids:
|
||||
@@ -1194,6 +1192,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
for resource_record in record.resources:
|
||||
resource_states[resource_record.get_node_id()] = resource_record
|
||||
register_loaded_resource_nodes()
|
||||
animal_care.restore_state_records(record.animals)
|
||||
village_changed.emit(village)
|
||||
state_restored.emit()
|
||||
return true
|
||||
|
||||
@@ -23,7 +23,8 @@ func select_action(
|
||||
time_of_day: float = 0.5,
|
||||
all_npcs: Array = [],
|
||||
opportunity_helper: OpportunityHelperResult = null,
|
||||
population_view: SimulationPopulationView = null
|
||||
population_view: SimulationPopulationView = null,
|
||||
animal_feed_name: String = ""
|
||||
) -> ActionSelectionResult:
|
||||
if npc.is_dead:
|
||||
return null
|
||||
@@ -126,6 +127,14 @@ func select_action(
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
||||
)
|
||||
if not animal_feed_name.is_empty():
|
||||
var feed_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
if _get_cost_rejection(feed_definition, village).is_empty():
|
||||
return ActionSelectionResult.new(
|
||||
SimulationIds.ACTION_FEED_ANIMAL,
|
||||
-1.0,
|
||||
"%s is hungry; pantry food is available" % animal_feed_name
|
||||
)
|
||||
if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
|
||||
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
|
||||
npc.id, all_npcs, population_view
|
||||
|
||||
@@ -17,6 +17,8 @@ func resolve(
|
||||
)
|
||||
SimulationIds.TARGET_ACTIVITY:
|
||||
return _resolve_activity(npc, origin, simulation_manager, active_world_adapter)
|
||||
SimulationIds.TARGET_ANIMAL:
|
||||
return _resolve_animal(npc, origin, simulation_manager, active_world_adapter)
|
||||
SimulationIds.TARGET_FREE:
|
||||
return {
|
||||
"target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id)
|
||||
@@ -202,6 +204,32 @@ func _resolve_activity(
|
||||
return active_world_adapter.get_activity_target(npc.current_task, origin)
|
||||
|
||||
|
||||
func _resolve_animal(
|
||||
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
|
||||
) -> Dictionary:
|
||||
if not active_world_adapter.has_method("get_animal_candidates"):
|
||||
return {}
|
||||
var best: Dictionary = {}
|
||||
var best_distance := INF
|
||||
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(npc.current_task)
|
||||
for candidate in candidates:
|
||||
var animal_id := StringName(candidate["target_id"])
|
||||
var state: AnimalStateRecord = simulation_manager.animal_care.get_state(animal_id)
|
||||
if state == null or not state.can_npc_feed() or not state.is_available_for(npc.id):
|
||||
continue
|
||||
var position: Vector3 = candidate["position"]
|
||||
var distance := origin.distance_squared_to(position)
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best = candidate
|
||||
if best.is_empty():
|
||||
return {}
|
||||
var target_id := StringName(best["target_id"])
|
||||
if not simulation_manager.animal_care.reserve(target_id, npc.id):
|
||||
return {}
|
||||
return best
|
||||
|
||||
|
||||
func score_resource_candidate(
|
||||
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
|
||||
) -> float:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
class_name AnimalCareSystem
|
||||
extends RefCounted
|
||||
|
||||
signal economic_event_requested(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
source_id: StringName,
|
||||
destination_id: StringName,
|
||||
item_id: StringName,
|
||||
amount: float,
|
||||
world_position: Vector3
|
||||
)
|
||||
signal narrative_event_requested(
|
||||
event_type: StringName,
|
||||
actor_id: int,
|
||||
source_id: StringName,
|
||||
action_display: String,
|
||||
action_id: StringName,
|
||||
item_id: StringName,
|
||||
required_amount: float
|
||||
)
|
||||
|
||||
var states: Dictionary = {}
|
||||
var economy: RefCounted
|
||||
var active_world_adapter: Node
|
||||
|
||||
|
||||
func configure(economy_service: RefCounted, world_adapter: Node) -> void:
|
||||
economy = economy_service
|
||||
active_world_adapter = world_adapter
|
||||
|
||||
|
||||
func advance() -> void:
|
||||
var animal_ids: Array = states.keys()
|
||||
animal_ids.sort()
|
||||
for animal_id in animal_ids:
|
||||
var animal_state := states[animal_id] as AnimalStateRecord
|
||||
if animal_state != null:
|
||||
animal_state.advance_tick()
|
||||
|
||||
|
||||
func register_loaded_nodes() -> void:
|
||||
for node in AnimalNode.get_all():
|
||||
register_node(node)
|
||||
|
||||
|
||||
func register_node(node: AnimalNode) -> bool:
|
||||
if node == null or node.animal_id.is_empty():
|
||||
return false
|
||||
if (
|
||||
ResourceNode.get_by_id(node.animal_id) != null
|
||||
or StorageNode.get_by_id(node.animal_id) != null
|
||||
or ActivitySite.get_by_id(node.animal_id) != null
|
||||
):
|
||||
push_error("AnimalCareSystem: Animal ID '%s' collides with a world target" % node.animal_id)
|
||||
return false
|
||||
var action_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
if (
|
||||
action_definition == null
|
||||
or action_definition.target_type != SimulationIds.TARGET_ANIMAL
|
||||
or node.species_id != SimulationIds.SPECIES_GOAT
|
||||
):
|
||||
push_error(
|
||||
(
|
||||
"AnimalCareSystem: AnimalNode '%s' has an invalid feed action or species '%s'"
|
||||
% [node.animal_id, node.species_id]
|
||||
)
|
||||
)
|
||||
return false
|
||||
var animal_state := get_state(node.animal_id)
|
||||
if animal_state == null:
|
||||
animal_state = AnimalStateRecord.create_from_node(node)
|
||||
states[node.animal_id] = animal_state
|
||||
elif not animal_state.apply_definition(node):
|
||||
push_error("AnimalCareSystem: AnimalNode definition mismatch for '%s'" % node.animal_id)
|
||||
return false
|
||||
return node.bind_state(animal_state)
|
||||
|
||||
|
||||
func get_state(animal_id: StringName) -> AnimalStateRecord:
|
||||
return states.get(animal_id) as AnimalStateRecord
|
||||
|
||||
|
||||
func reserve(animal_id: StringName, agent_id: int) -> bool:
|
||||
var animal_state := get_state(animal_id)
|
||||
return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id)
|
||||
|
||||
|
||||
func release(animal_id: StringName, agent_id: int) -> void:
|
||||
var animal_state := get_state(animal_id)
|
||||
if animal_state != null:
|
||||
animal_state.release(agent_id)
|
||||
|
||||
|
||||
func can_complete_feed(animal_id: StringName, agent_id: int) -> bool:
|
||||
var animal_state := get_state(animal_id)
|
||||
return (
|
||||
animal_state != null
|
||||
and animal_state.needs_feed()
|
||||
and animal_state.get_reserved_by() == agent_id
|
||||
)
|
||||
|
||||
|
||||
func get_available_feed_name(npc_id: int) -> String:
|
||||
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
|
||||
return ""
|
||||
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
|
||||
SimulationIds.ACTION_FEED_ANIMAL
|
||||
)
|
||||
for candidate in candidates:
|
||||
var animal_state := get_state(StringName(candidate["target_id"]))
|
||||
if (
|
||||
animal_state != null
|
||||
and animal_state.can_npc_feed()
|
||||
and animal_state.is_available_for(npc_id)
|
||||
):
|
||||
return animal_state.get_display_name()
|
||||
return ""
|
||||
|
||||
|
||||
func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode:
|
||||
if (
|
||||
active_world_adapter == null
|
||||
or not active_world_adapter.has_method("get_animal_nodes_in_radius")
|
||||
):
|
||||
return null
|
||||
var candidates: Array[AnimalNode] = active_world_adapter.get_animal_nodes_in_radius(
|
||||
from_position, max_distance
|
||||
)
|
||||
for node in candidates:
|
||||
var animal_state := get_state(node.animal_id)
|
||||
if (
|
||||
animal_state != null
|
||||
and animal_state.can_player_feed_animal()
|
||||
and animal_state.needs_feed()
|
||||
):
|
||||
return node
|
||||
return null
|
||||
|
||||
|
||||
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
|
||||
var animal_state := get_state(animal_id)
|
||||
if animal_state == null or not animal_state.needs_feed():
|
||||
return false
|
||||
if actor_id >= 0:
|
||||
if (
|
||||
_find_npc(actor_id, npcs) == null
|
||||
or not animal_state.can_npc_feed()
|
||||
or animal_state.get_reserved_by() != actor_id
|
||||
):
|
||||
return false
|
||||
elif not animal_state.can_player_feed_animal():
|
||||
return false
|
||||
|
||||
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
if definition == null or not definition.has_completion_cost() or economy == null:
|
||||
return false
|
||||
var resource_id := definition.completion_cost_resource_id
|
||||
var required_amount := definition.completion_cost_amount
|
||||
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
|
||||
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
|
||||
if pantry == null or available < required_amount:
|
||||
if actor_id >= 0:
|
||||
(
|
||||
narrative_event_requested
|
||||
. emit(
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
actor_id,
|
||||
pantry.get_storage_id() if pantry != null else &"",
|
||||
(
|
||||
"%s: needs %.0f %s (%.1f available)"
|
||||
% [
|
||||
definition.display_name,
|
||||
required_amount,
|
||||
String(resource_id).capitalize(),
|
||||
available,
|
||||
]
|
||||
),
|
||||
definition.action_id,
|
||||
resource_id,
|
||||
required_amount
|
||||
)
|
||||
)
|
||||
return false
|
||||
|
||||
var consumed: float = economy.withdraw_resource(resource_id, required_amount)
|
||||
if consumed < required_amount:
|
||||
economy.deposit_resource(resource_id, consumed)
|
||||
return false
|
||||
if not animal_state.feed(actor_id, current_tick):
|
||||
economy.deposit_resource(resource_id, consumed)
|
||||
return false
|
||||
|
||||
economic_event_requested.emit(
|
||||
SimulationIds.EVENT_ANIMAL_FED,
|
||||
actor_id,
|
||||
pantry.get_storage_id(),
|
||||
animal_state.get_animal_id(),
|
||||
resource_id,
|
||||
consumed,
|
||||
animal_state.get_position()
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
func append_state_records(record: SimulationStateRecord) -> void:
|
||||
var animal_ids: Array = states.keys()
|
||||
animal_ids.sort()
|
||||
for animal_id in animal_ids:
|
||||
var animal_state := states[animal_id] as AnimalStateRecord
|
||||
record.animals.append(animal_state)
|
||||
|
||||
|
||||
func restore_state_records(records: Array[AnimalStateRecord]) -> void:
|
||||
states.clear()
|
||||
for animal_record in records:
|
||||
states[animal_record.get_animal_id()] = animal_record
|
||||
register_loaded_nodes()
|
||||
|
||||
|
||||
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
return npc
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://kilnbq2k276w
|
||||
@@ -22,7 +22,10 @@ func validate() -> Array[String]:
|
||||
if (
|
||||
target_type
|
||||
not in [
|
||||
SimulationIds.TARGET_RESOURCE, SimulationIds.TARGET_ACTIVITY, SimulationIds.TARGET_FREE
|
||||
SimulationIds.TARGET_RESOURCE,
|
||||
SimulationIds.TARGET_ACTIVITY,
|
||||
SimulationIds.TARGET_ANIMAL,
|
||||
SimulationIds.TARGET_FREE,
|
||||
]
|
||||
):
|
||||
errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id])
|
||||
|
||||
@@ -4,6 +4,7 @@ extends RefCounted
|
||||
const ACTION_PATHS := [
|
||||
"res://simulation/definitions/actions/gather_food.tres",
|
||||
"res://simulation/definitions/actions/gather_wood.tres",
|
||||
"res://simulation/definitions/actions/feed_animal.tres",
|
||||
"res://simulation/definitions/actions/patrol.tres",
|
||||
"res://simulation/definitions/actions/study.tres",
|
||||
"res://simulation/definitions/actions/eat.tres",
|
||||
|
||||
@@ -6,6 +6,7 @@ const ACTION_DEAD := &"dead"
|
||||
const ACTION_SLEEP := &"sleep"
|
||||
const ACTION_GATHER_FOOD := &"gather_food"
|
||||
const ACTION_GATHER_WOOD := &"gather_wood"
|
||||
const ACTION_FEED_ANIMAL := &"feed_animal"
|
||||
const ACTION_PATROL := &"patrol"
|
||||
const ACTION_STUDY := &"study"
|
||||
const ACTION_EAT := &"eat"
|
||||
@@ -23,11 +24,15 @@ const PROFESSION_WANDERER := &"wanderer"
|
||||
|
||||
const TARGET_RESOURCE := &"resource"
|
||||
const TARGET_ACTIVITY := &"activity"
|
||||
const TARGET_ANIMAL := &"animal"
|
||||
const TARGET_FREE := &"free"
|
||||
|
||||
const RESOURCE_FOOD := &"food"
|
||||
const RESOURCE_WOOD := &"wood"
|
||||
|
||||
const ANIMAL_DUNJA := &"goat_dunja"
|
||||
const SPECIES_GOAT := &"goat"
|
||||
|
||||
const STORAGE_VILLAGE_PANTRY := &"village_pantry"
|
||||
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
|
||||
|
||||
@@ -40,6 +45,7 @@ const EVENT_NPC_DIED := &"npc_died"
|
||||
const EVENT_TASK_STARTED := &"task_started"
|
||||
const EVENT_TASK_BLOCKED := &"task_blocked"
|
||||
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
||||
const EVENT_ANIMAL_FED := &"animal_fed"
|
||||
|
||||
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
|
||||
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[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 = &"feed_animal"
|
||||
display_name = "Feed Dunja"
|
||||
default_duration = 2.0
|
||||
preferred_profession_id = &"farmer"
|
||||
target_type = &"animal"
|
||||
completion_cost_resource_id = &"food"
|
||||
completion_cost_amount = 1.0
|
||||
@@ -0,0 +1,223 @@
|
||||
class_name AnimalStateRecord
|
||||
extends RefCounted
|
||||
|
||||
signal changed(state: AnimalStateRecord)
|
||||
signal fed(state: AnimalStateRecord, actor_id: int)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const HUNGER_PER_TICK := 0.125
|
||||
const FEED_THRESHOLD := 65.0
|
||||
const HUNGER_RELIEF := 55.0
|
||||
const NEVER_FED_TICK := -1
|
||||
|
||||
var data: Dictionary
|
||||
|
||||
|
||||
func _init(record_data: Dictionary = {}) -> void:
|
||||
data = record_data.duplicate(true)
|
||||
|
||||
|
||||
static func create_from_node(node: AnimalNode) -> AnimalStateRecord:
|
||||
return (
|
||||
AnimalStateRecord
|
||||
. new(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"animal_id": String(node.animal_id),
|
||||
"display_name": node.display_name,
|
||||
"species_id": String(node.species_id),
|
||||
"position":
|
||||
[node.global_position.x, node.global_position.y, node.global_position.z],
|
||||
"hunger": node.initial_hunger,
|
||||
"last_fed_tick": NEVER_FED_TICK,
|
||||
"reserved_by": -1,
|
||||
"enabled": node.initial_enabled,
|
||||
"can_npcs_feed": node.can_npcs_feed,
|
||||
"can_player_feed": node.can_player_feed,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord:
|
||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not (
|
||||
record_data
|
||||
. has_all(
|
||||
[
|
||||
"animal_id",
|
||||
"display_name",
|
||||
"species_id",
|
||||
"position",
|
||||
"hunger",
|
||||
"last_fed_tick",
|
||||
"reserved_by",
|
||||
"enabled",
|
||||
"can_npcs_feed",
|
||||
"can_player_feed",
|
||||
]
|
||||
)
|
||||
):
|
||||
return null
|
||||
var animal_id := String(record_data["animal_id"])
|
||||
var display_name := String(record_data["display_name"])
|
||||
var species_id := String(record_data["species_id"])
|
||||
var saved_position = record_data["position"]
|
||||
var hunger := float(record_data["hunger"])
|
||||
var last_fed_tick := int(record_data["last_fed_tick"])
|
||||
var reserved_by := int(record_data["reserved_by"])
|
||||
if animal_id.is_empty() or display_name.is_empty() or species_id.is_empty():
|
||||
return null
|
||||
if StringName(species_id) != SimulationIds.SPECIES_GOAT:
|
||||
return null
|
||||
if not saved_position is Array or saved_position.size() != 3:
|
||||
return null
|
||||
for component in saved_position:
|
||||
if not is_finite(float(component)):
|
||||
return null
|
||||
if not is_finite(hunger) or hunger < 0.0 or hunger > 100.0:
|
||||
return null
|
||||
if last_fed_tick < NEVER_FED_TICK or reserved_by < -1:
|
||||
return null
|
||||
if reserved_by >= 0 and hunger < FEED_THRESHOLD:
|
||||
return null
|
||||
|
||||
var normalized := record_data.duplicate(true)
|
||||
normalized["schema_version"] = SCHEMA_VERSION
|
||||
normalized["animal_id"] = animal_id
|
||||
normalized["display_name"] = display_name
|
||||
normalized["species_id"] = species_id
|
||||
normalized["position"] = [
|
||||
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
||||
]
|
||||
normalized["hunger"] = hunger
|
||||
normalized["last_fed_tick"] = last_fed_tick
|
||||
normalized["reserved_by"] = reserved_by
|
||||
normalized["enabled"] = bool(record_data["enabled"])
|
||||
normalized["can_npcs_feed"] = bool(record_data["can_npcs_feed"])
|
||||
normalized["can_player_feed"] = bool(record_data["can_player_feed"])
|
||||
return AnimalStateRecord.new(normalized)
|
||||
|
||||
|
||||
func apply_definition(node: AnimalNode) -> bool:
|
||||
if node == null or node.animal_id != get_animal_id():
|
||||
return false
|
||||
return (
|
||||
node.display_name == get_display_name()
|
||||
and node.species_id == get_species_id()
|
||||
and node.can_npcs_feed == can_npc_feed()
|
||||
and node.can_player_feed == can_player_feed_animal()
|
||||
)
|
||||
|
||||
|
||||
func get_animal_id() -> StringName:
|
||||
return StringName(data["animal_id"])
|
||||
|
||||
|
||||
func get_display_name() -> String:
|
||||
return String(data["display_name"])
|
||||
|
||||
|
||||
func get_species_id() -> StringName:
|
||||
return StringName(data["species_id"])
|
||||
|
||||
|
||||
func get_position() -> Vector3:
|
||||
var saved_position: Array = data["position"]
|
||||
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
|
||||
|
||||
|
||||
func get_hunger() -> float:
|
||||
return float(data["hunger"])
|
||||
|
||||
|
||||
func get_last_fed_tick() -> int:
|
||||
return int(data["last_fed_tick"])
|
||||
|
||||
|
||||
func get_reserved_by() -> int:
|
||||
return int(data["reserved_by"])
|
||||
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return bool(data["enabled"])
|
||||
|
||||
|
||||
func can_npc_feed() -> bool:
|
||||
return bool(data["can_npcs_feed"])
|
||||
|
||||
|
||||
func can_player_feed_animal() -> bool:
|
||||
return bool(data["can_player_feed"])
|
||||
|
||||
|
||||
func needs_feed() -> bool:
|
||||
return is_enabled() and get_hunger() >= FEED_THRESHOLD
|
||||
|
||||
|
||||
func is_available_for(agent_id: int) -> bool:
|
||||
return needs_feed() and (get_reserved_by() == -1 or get_reserved_by() == agent_id)
|
||||
|
||||
|
||||
func reserve(agent_id: int) -> bool:
|
||||
if agent_id < 0 or not is_available_for(agent_id):
|
||||
return false
|
||||
data["reserved_by"] = agent_id
|
||||
changed.emit(self)
|
||||
return true
|
||||
|
||||
|
||||
func release(agent_id: int) -> void:
|
||||
if get_reserved_by() != agent_id:
|
||||
return
|
||||
data["reserved_by"] = -1
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func advance_tick() -> void:
|
||||
if not is_enabled():
|
||||
return
|
||||
var next_hunger := minf(get_hunger() + HUNGER_PER_TICK, 100.0)
|
||||
if is_equal_approx(next_hunger, get_hunger()):
|
||||
return
|
||||
data["hunger"] = next_hunger
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func feed(actor_id: int, current_tick: int) -> bool:
|
||||
if not needs_feed() or current_tick < 0:
|
||||
return false
|
||||
data["hunger"] = maxf(get_hunger() - HUNGER_RELIEF, 0.0)
|
||||
data["last_fed_tick"] = current_tick
|
||||
data["reserved_by"] = -1
|
||||
changed.emit(self)
|
||||
fed.emit(self, actor_id)
|
||||
return true
|
||||
|
||||
|
||||
func set_position(value: Vector3) -> void:
|
||||
if not value.is_finite() or value.is_equal_approx(get_position()):
|
||||
return
|
||||
data["position"] = [value.x, value.y, value.z]
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_hunger(value: float) -> void:
|
||||
if not is_finite(value):
|
||||
return
|
||||
data["hunger"] = clampf(value, 0.0, 100.0)
|
||||
if not needs_feed():
|
||||
data["reserved_by"] = -1
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func set_enabled(value: bool) -> void:
|
||||
data["enabled"] = value
|
||||
if not value:
|
||||
data["reserved_by"] = -1
|
||||
changed.emit(self)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return data.duplicate(true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8rgw7h25bcmr
|
||||
@@ -158,5 +158,7 @@ func description(npc_names: Dictionary = {}) -> String:
|
||||
return "%s could not complete %s" % [actor_name, action_name]
|
||||
"resource_depleted":
|
||||
return "%s was depleted" % source
|
||||
"animal_fed":
|
||||
return "%s fed %s %.0f %s" % [actor_name, destination, amount, item]
|
||||
_:
|
||||
return "tick %d: %s" % [int(data["tick"]), event_type]
|
||||
|
||||
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_NAME := "the_steward.simulation"
|
||||
const SCHEMA_VERSION := 9
|
||||
const SCHEMA_VERSION := 10
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||
@@ -10,11 +10,13 @@ const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
|
||||
const PROVENANCE_LEGACY_SCHEMA_VERSION := 5
|
||||
const RETENTION_LEGACY_SCHEMA_VERSION := 6
|
||||
const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8
|
||||
const ANIMAL_LEGACY_SCHEMA_VERSION := 9
|
||||
const PREVIOUS_SCHEMA_VERSION := 7
|
||||
|
||||
var simulation: Dictionary
|
||||
var village: VillageStateRecord
|
||||
var npcs: Array[NPCStateRecord] = []
|
||||
var animals: Array[AnimalStateRecord] = []
|
||||
var resources: Array[ResourceStateRecord] = []
|
||||
var storages: Array[StorageStateRecord] = []
|
||||
var economic_events: Array[EconomicEventRecord] = []
|
||||
@@ -28,6 +30,9 @@ func to_dictionary() -> Dictionary:
|
||||
for npc_record in npcs:
|
||||
npc_data.append(npc_record.to_dictionary())
|
||||
|
||||
var animal_data: Array[Dictionary] = []
|
||||
for animal_record in animals:
|
||||
animal_data.append(animal_record.to_dictionary())
|
||||
var resource_data: Array[Dictionary] = []
|
||||
for resource_record in resources:
|
||||
resource_data.append(resource_record.to_dictionary())
|
||||
@@ -53,6 +58,7 @@ func to_dictionary() -> Dictionary:
|
||||
"simulation": simulation.duplicate(true),
|
||||
"village": village.to_dictionary(),
|
||||
"npcs": npc_data,
|
||||
"animals": animal_data,
|
||||
"resources": resource_data,
|
||||
"storages": storage_data,
|
||||
"economic_events": event_data,
|
||||
@@ -88,6 +94,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
RETENTION_LEGACY_SCHEMA_VERSION,
|
||||
PREVIOUS_SCHEMA_VERSION,
|
||||
OPPORTUNITY_LEGACY_SCHEMA_VERSION,
|
||||
ANIMAL_LEGACY_SCHEMA_VERSION,
|
||||
]
|
||||
):
|
||||
record_data = _migrate_legacy(record_data, version)
|
||||
@@ -100,6 +107,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
"simulation",
|
||||
"village",
|
||||
"npcs",
|
||||
"animals",
|
||||
"resources",
|
||||
"storages",
|
||||
"economic_events",
|
||||
@@ -166,6 +174,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
return null
|
||||
|
||||
var npc_data = record_data["npcs"]
|
||||
var animal_data = record_data["animals"]
|
||||
var resource_data = record_data["resources"]
|
||||
var storage_data = record_data["storages"]
|
||||
var event_data = record_data["economic_events"]
|
||||
@@ -174,6 +183,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
var opportunity_data = record_data["opportunities"]
|
||||
if (
|
||||
not npc_data is Array
|
||||
or not animal_data is Array
|
||||
or not resource_data is Array
|
||||
or not storage_data is Array
|
||||
or not event_data is Array
|
||||
@@ -217,6 +227,51 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
resource_records_by_id[resource_id] = resource_record
|
||||
record.resources.append(resource_record)
|
||||
|
||||
var animal_ids := {}
|
||||
var animal_records_by_id := {}
|
||||
for item in animal_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
var animal_record := AnimalStateRecord.from_dictionary(item)
|
||||
if animal_record == null:
|
||||
return null
|
||||
var animal_id := String(animal_record.data["animal_id"])
|
||||
if animal_ids.has(animal_id) or resource_ids.has(animal_id):
|
||||
return null
|
||||
var reserved_by := animal_record.get_reserved_by()
|
||||
if reserved_by >= 0:
|
||||
var reserving_npc := npc_records_by_id.get(reserved_by) as NPCStateRecord
|
||||
if (
|
||||
reserving_npc == null
|
||||
or (
|
||||
StringName(reserving_npc.data["current_task"])
|
||||
!= SimulationIds.ACTION_FEED_ANIMAL
|
||||
)
|
||||
or StringName(reserving_npc.data["target_id"]) != animal_record.get_animal_id()
|
||||
or (
|
||||
StringName(reserving_npc.data["task_state"])
|
||||
not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
|
||||
)
|
||||
):
|
||||
return null
|
||||
animal_ids[animal_id] = true
|
||||
animal_records_by_id[animal_id] = animal_record
|
||||
record.animals.append(animal_record)
|
||||
for npc_record in record.npcs:
|
||||
if (
|
||||
StringName(npc_record.data["current_task"]) != SimulationIds.ACTION_FEED_ANIMAL
|
||||
or (
|
||||
StringName(npc_record.data["task_state"])
|
||||
not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
|
||||
)
|
||||
):
|
||||
continue
|
||||
var target_animal := (
|
||||
animal_records_by_id.get(StringName(npc_record.data["target_id"])) as AnimalStateRecord
|
||||
)
|
||||
if target_animal == null or target_animal.get_reserved_by() != int(npc_record.data["id"]):
|
||||
return null
|
||||
|
||||
var storage_ids := {}
|
||||
var storage_records_by_id := {}
|
||||
for item in storage_data:
|
||||
@@ -226,7 +281,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
if storage_record == null:
|
||||
return null
|
||||
var storage_id := storage_record.get_storage_id()
|
||||
if storage_ids.has(storage_id):
|
||||
if storage_ids.has(storage_id) or animal_ids.has(String(storage_id)):
|
||||
return null
|
||||
storage_ids[storage_id] = true
|
||||
storage_records_by_id[storage_id] = storage_record
|
||||
@@ -235,6 +290,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
var event_ids := {}
|
||||
var event_records_by_id := {}
|
||||
var highest_event_id := -1
|
||||
var latest_feed_ticks := {}
|
||||
for item in event_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
@@ -247,9 +303,33 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
event_ids[event_id] = true
|
||||
event_records_by_id[event_id] = event_record
|
||||
highest_event_id = maxi(highest_event_id, event_id)
|
||||
if StringName(event_record.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
|
||||
if not _is_valid_animal_feed_event(
|
||||
event_record,
|
||||
npc_ids,
|
||||
animal_records_by_id,
|
||||
storage_records_by_id,
|
||||
int(record.simulation["tick_count"])
|
||||
):
|
||||
return null
|
||||
var fed_animal_id := StringName(event_record.data["destination_id"])
|
||||
latest_feed_ticks[fed_animal_id] = maxi(
|
||||
int(latest_feed_ticks.get(fed_animal_id, AnimalStateRecord.NEVER_FED_TICK)),
|
||||
int(event_record.data["tick"])
|
||||
)
|
||||
record.economic_events.append(event_record)
|
||||
if int(record.simulation["next_event_id"]) <= highest_event_id:
|
||||
return null
|
||||
for animal_record in record.animals:
|
||||
if (
|
||||
animal_record.get_last_fed_tick()
|
||||
!= int(
|
||||
latest_feed_ticks.get(
|
||||
animal_record.get_animal_id(), AnimalStateRecord.NEVER_FED_TICK
|
||||
)
|
||||
)
|
||||
):
|
||||
return null
|
||||
|
||||
var knowledge_keys := {}
|
||||
var knowledge_records_by_key := {}
|
||||
@@ -359,6 +439,31 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
return record
|
||||
|
||||
|
||||
static func _is_valid_animal_feed_event(
|
||||
event: EconomicEventRecord,
|
||||
npc_ids: Dictionary,
|
||||
animal_records_by_id: Dictionary,
|
||||
storage_records_by_id: Dictionary,
|
||||
current_tick: int
|
||||
) -> bool:
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
var animal := (
|
||||
animal_records_by_id.get(StringName(event.data["destination_id"])) as AnimalStateRecord
|
||||
)
|
||||
return (
|
||||
(actor_id == -1 or npc_ids.has(actor_id))
|
||||
and int(event.data["tick"]) <= current_tick
|
||||
and animal != null
|
||||
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and storage_records_by_id.has(SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
and definition != null
|
||||
and definition.has_completion_cost()
|
||||
and is_equal_approx(float(event.data["amount"]), definition.completion_cost_amount)
|
||||
)
|
||||
|
||||
|
||||
static func _is_valid_knowledge_provenance(
|
||||
known_event: KnownEventStateRecord,
|
||||
npc_ids: Dictionary,
|
||||
@@ -571,6 +676,7 @@ static func _is_valid_supply_resolution(
|
||||
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
|
||||
var migrated := legacy_data.duplicate(true)
|
||||
migrated["schema_version"] = SCHEMA_VERSION
|
||||
migrated["animals"] = []
|
||||
if version == LEGACY_SCHEMA_VERSION:
|
||||
var village_data: Dictionary = legacy_data.get("village", {})
|
||||
var initial_food := float(village_data.get("food", 0.0))
|
||||
@@ -621,7 +727,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
migrated.get("economic_events", []),
|
||||
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
|
||||
)
|
||||
if version != OPPORTUNITY_LEGACY_SCHEMA_VERSION:
|
||||
if version not in [OPPORTUNITY_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION]:
|
||||
migrated["opportunities"] = []
|
||||
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
|
||||
opportunity_simulation_data["next_opportunity_id"] = 0
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var main_scene: Node = load("res://main.tscn").instantiate()
|
||||
root.add_child(main_scene)
|
||||
await process_frame
|
||||
var manager: Node = main_scene.get_node("SimulationManager")
|
||||
manager.set_process(false)
|
||||
for _frame in 3:
|
||||
await physics_frame
|
||||
|
||||
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
|
||||
var player := main_scene.get_node("Player") as Node3D
|
||||
var pantry: StorageStateRecord = manager.get_pantry()
|
||||
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
|
||||
_check(goat != null, "Jajce should load one named goat presentation")
|
||||
_check(
|
||||
AnimalNode.get_all().size() == 1 and ResourceNode.get_all().size() == 18,
|
||||
"The goat should be one AnimalNode without changing the finite-resource count"
|
||||
)
|
||||
_check(
|
||||
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
|
||||
"Dunja must not masquerade as a ResourceNode",
|
||||
)
|
||||
_check(
|
||||
(
|
||||
goat.animal_id == SimulationIds.ANIMAL_DUNJA
|
||||
and goat.display_name == "Dunja"
|
||||
and goat.species_id == SimulationIds.SPECIES_GOAT
|
||||
and state != null
|
||||
and goat.state == state
|
||||
),
|
||||
"The loaded presentation should bind the stable named-goat identity"
|
||||
)
|
||||
if state == null:
|
||||
_finish()
|
||||
return
|
||||
|
||||
var authored_position := state.get_position()
|
||||
_check(
|
||||
goat.global_position.is_equal_approx(authored_position),
|
||||
"Animal state should own the loaded presentation position"
|
||||
)
|
||||
state.set_position(authored_position + Vector3(0.25, 0.0, 0.0))
|
||||
_check(
|
||||
goat.global_position.is_equal_approx(state.get_position()),
|
||||
"A simulation position change should move the bound goat presentation"
|
||||
)
|
||||
state.set_position(authored_position)
|
||||
|
||||
for index in range(1, manager.npcs.size()):
|
||||
_park_npc(manager.npcs[index])
|
||||
var caretaker: SimNPC = manager.npcs[0]
|
||||
manager.release_npc_reservation(caretaker.id)
|
||||
caretaker.inventory.clear()
|
||||
caretaker.hunger = 20.0
|
||||
caretaker.energy = 90.0
|
||||
caretaker.current_task = SimulationIds.ACTION_IDLE
|
||||
caretaker.task_state = SimNPC.TASK_STATE_IDLE
|
||||
caretaker.task_complete = true
|
||||
caretaker.target_id = &""
|
||||
caretaker.has_travel_target = false
|
||||
state.set_hunger(72.0)
|
||||
var pantry_before_npc := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
|
||||
manager.simulate_tick()
|
||||
_check(
|
||||
caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL,
|
||||
"An available villager should autonomously choose to feed hungry Dunja"
|
||||
)
|
||||
if caretaker.target_id.is_empty():
|
||||
manager.resolve_npc_target(caretaker.id, caretaker.position)
|
||||
_check(
|
||||
(
|
||||
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
|
||||
and state.get_reserved_by() == caretaker.id
|
||||
and caretaker.travel_target_position.is_equal_approx(goat.get_interaction_position())
|
||||
),
|
||||
"Animal target resolution should claim Dunja's loaded interaction point by stable ID"
|
||||
)
|
||||
manager.notify_npc_arrived(caretaker.id)
|
||||
manager.simulate_tick()
|
||||
var hunger_before_completion := state.get_hunger()
|
||||
manager.simulate_tick()
|
||||
|
||||
_check(
|
||||
is_equal_approx(
|
||||
state.get_hunger(),
|
||||
(
|
||||
hunger_before_completion
|
||||
+ AnimalStateRecord.HUNGER_PER_TICK
|
||||
- AnimalStateRecord.HUNGER_RELIEF
|
||||
)
|
||||
),
|
||||
"NPC feeding should apply the exact simulation-owned hunger relief"
|
||||
)
|
||||
_check(
|
||||
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_npc - 1.0),
|
||||
"NPC feeding should withdraw exactly one real pantry food"
|
||||
)
|
||||
_check(
|
||||
state.get_last_fed_tick() == manager.tick_count and state.get_reserved_by() == -1,
|
||||
"Completed feeding should retain its deterministic tick and release the claim"
|
||||
)
|
||||
var npc_feed_event := _latest_feed_event(manager)
|
||||
_check(
|
||||
(
|
||||
npc_feed_event != null
|
||||
and int(npc_feed_event.data["actor_id"]) == caretaker.id
|
||||
and (
|
||||
StringName(npc_feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
)
|
||||
and (StringName(npc_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
|
||||
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
and is_equal_approx(float(npc_feed_event.data["amount"]), 1.0)
|
||||
and npc_feed_event.get_world_position().is_equal_approx(state.get_position())
|
||||
),
|
||||
"NPC feeding should record one exact pantry-to-animal transfer fact"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
not goat.get_node("Visual/HungerCueRoot").visible
|
||||
and goat.get_node("Visual/FedResponseRoot").visible
|
||||
),
|
||||
"Real feeding should clear the stable hunger cue and play one cozy response"
|
||||
)
|
||||
|
||||
var npc_feed_json: String = manager.serialize_state()
|
||||
var npc_feed_checksum: String = manager.get_state_checksum()
|
||||
var saved_hunger := state.get_hunger()
|
||||
var saved_fed_tick := state.get_last_fed_tick()
|
||||
_check(
|
||||
manager.restore_state_from_json(npc_feed_json),
|
||||
"NPC-fed animal state should pass the complete schema and restore"
|
||||
)
|
||||
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
|
||||
pantry = manager.get_pantry()
|
||||
_check(
|
||||
(
|
||||
goat.state == state
|
||||
and state.get_position().is_equal_approx(authored_position)
|
||||
and is_equal_approx(state.get_hunger(), saved_hunger)
|
||||
and state.get_last_fed_tick() == saved_fed_tick
|
||||
and manager.get_state_checksum() == npc_feed_checksum
|
||||
),
|
||||
"Animal position, hunger, feed tick, and checksum should round-trip deterministically"
|
||||
)
|
||||
_check(
|
||||
not goat.get_node("Visual/FedResponseRoot").visible,
|
||||
"Restore should rebuild stable animal cues without replaying transient praise"
|
||||
)
|
||||
|
||||
state.set_hunger(72.0)
|
||||
var pantry_before_player := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
player.global_position = goat.get_interaction_position()
|
||||
player.call("try_interact")
|
||||
var player_feed_event := _latest_feed_event(manager)
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(
|
||||
pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_player - 1.0
|
||||
)
|
||||
and is_equal_approx(state.get_hunger(), 72.0 - AnimalStateRecord.HUNGER_RELIEF)
|
||||
and player_feed_event != null
|
||||
and int(player_feed_event.data["actor_id"]) == -1
|
||||
and (StringName(player_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
|
||||
),
|
||||
"The player should use the same one-food feed operation and event contract"
|
||||
)
|
||||
var player_feed_json: String = manager.serialize_state()
|
||||
_check(
|
||||
manager.restore_state_from_json(player_feed_json),
|
||||
"Player-fed animal history should restore through the same schema"
|
||||
)
|
||||
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
|
||||
pantry = manager.get_pantry()
|
||||
_check(
|
||||
not goat.get_node("Visual/FedResponseRoot").visible,
|
||||
"Player feed feedback should also remain transient across restore"
|
||||
)
|
||||
|
||||
state.set_hunger(72.0)
|
||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||
var events_before_failed_feed: int = manager.economic_events.size()
|
||||
player.global_position = goat.get_interaction_position()
|
||||
player.call("try_interact")
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(state.get_hunger(), 72.0)
|
||||
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
|
||||
and manager.economic_events.size() == events_before_failed_feed
|
||||
),
|
||||
"An empty pantry should block feeding without inventing food or animal relief"
|
||||
)
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _park_npc(npc: SimNPC) -> void:
|
||||
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
|
||||
npc.start_working()
|
||||
|
||||
|
||||
func _latest_feed_event(manager: Node) -> EconomicEventRecord:
|
||||
for index in range(manager.economic_events.size() - 1, -1, -1):
|
||||
var event := manager.economic_events[index] as EconomicEventRecord
|
||||
if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
|
||||
return event
|
||||
return null
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Animal feeding vertical slice passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgnst4m7bp3tb
|
||||
@@ -44,8 +44,8 @@ func _run_scenario(seed_value: int) -> String:
|
||||
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
|
||||
manager.notify_npc_arrived(npc.id)
|
||||
|
||||
var snapshot: Dictionary = manager.get_state_snapshot()
|
||||
snapshot.erase("seed")
|
||||
var snapshot: Dictionary = manager.create_state_record().to_dictionary()
|
||||
snapshot["simulation"].erase("seed")
|
||||
var checksum := JSON.stringify(snapshot).sha256_text()
|
||||
manager.free()
|
||||
return checksum
|
||||
|
||||
@@ -105,7 +105,7 @@ func _run() -> void:
|
||||
var active_restored := _create_manager(902)
|
||||
_check(
|
||||
active_restored.restore_state_from_json(active_json),
|
||||
"An active opportunity should survive schema-v9 save and restore"
|
||||
"An active opportunity should survive schema-v10 save and restore"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
|
||||
@@ -269,7 +269,7 @@ func _run() -> void:
|
||||
var old_contributor_visual := contributor_visual
|
||||
_check(
|
||||
simulation_manager.restore_state_from_json(active_need_json),
|
||||
"The visible shortage should survive a valid schema-v9 restore"
|
||||
"The visible shortage should survive a valid schema-v10 restore"
|
||||
)
|
||||
await process_frame
|
||||
contributor = simulation_manager.npcs[contributor.id]
|
||||
|
||||
@@ -396,6 +396,24 @@ func _run() -> void:
|
||||
)
|
||||
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
|
||||
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
|
||||
var animal_root := world.get_node("WorldObjects/Animals")
|
||||
var goat := animal_root.get_node("Dunja") as AnimalNode
|
||||
_check(
|
||||
animal_root.get_child_count() == 1 and goat != null,
|
||||
"Jajce should contain one bounded loaded-animal presentation"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
goat.animal_id == SimulationIds.ANIMAL_DUNJA
|
||||
and goat.species_id == SimulationIds.SPECIES_GOAT
|
||||
and goat.display_name == "Dunja"
|
||||
),
|
||||
"The authored goat should preserve its stable identity and readable name"
|
||||
)
|
||||
_check(
|
||||
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
|
||||
"The identity-backed goat should remain separate from finite resources"
|
||||
)
|
||||
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
|
||||
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
|
||||
@@ -448,6 +466,7 @@ func _run() -> void:
|
||||
var destinations: Array[Vector3] = []
|
||||
for resource in resources:
|
||||
destinations.append((resource as ResourceNode).interaction_point.global_position)
|
||||
destinations.append(goat.get_interaction_position())
|
||||
destinations.append(pantry.get_interaction_position())
|
||||
destinations.append(woodpile.get_interaction_position())
|
||||
for site in activity_root.get_children():
|
||||
|
||||
@@ -35,7 +35,7 @@ func _test_registry_integrity() -> void:
|
||||
"Action lookup should return '%s'" % definition.action_id
|
||||
)
|
||||
_check(
|
||||
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions"
|
||||
action_ids.size() == 12, "Registry should contain all twelve executable prototype actions"
|
||||
)
|
||||
|
||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||
@@ -88,6 +88,16 @@ func _test_definition_backed_behavior() -> void:
|
||||
),
|
||||
"Gather-food target metadata should come from its definition"
|
||||
)
|
||||
var feed := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
|
||||
_check(
|
||||
(
|
||||
feed.target_type == SimulationIds.TARGET_ANIMAL
|
||||
and feed.preferred_profession_id == SimulationIds.PROFESSION_FARMER
|
||||
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
|
||||
and is_equal_approx(feed.completion_cost_amount, 1.0)
|
||||
),
|
||||
"Feed-animal should target an animal and declare its exact pantry-food cost"
|
||||
)
|
||||
|
||||
|
||||
func _test_state_reference_validation() -> void:
|
||||
|
||||
@@ -19,8 +19,10 @@ func _run() -> void:
|
||||
_test_previous_world_provenance_migration()
|
||||
_test_previous_world_retention_migration()
|
||||
_test_previous_world_opportunity_migration()
|
||||
_test_previous_world_animal_migration()
|
||||
_test_relationship_schema_rejection()
|
||||
_test_opportunity_schema_rejection()
|
||||
_test_animal_schema_rejection()
|
||||
_test_schema_rejection()
|
||||
|
||||
if failures.is_empty():
|
||||
@@ -481,6 +483,51 @@ func _test_previous_world_opportunity_migration() -> void:
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_previous_world_animal_migration() -> void:
|
||||
var manager := _create_manager(63)
|
||||
var v9_data: Dictionary = _build_opportunity_world(manager, false)
|
||||
v9_data["schema_version"] = SimulationStateRecord.ANIMAL_LEGACY_SCHEMA_VERSION
|
||||
v9_data.erase("animals")
|
||||
var migrated := SimulationStateRecord.from_dictionary(v9_data)
|
||||
_check(migrated != null, "World schema v9 should add an empty animal stream")
|
||||
if migrated != null:
|
||||
_check(
|
||||
migrated.animals.is_empty() and migrated.opportunities.size() == 1,
|
||||
"World v9 migration should preserve opportunity history while adding animals"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_animal_schema_rejection() -> void:
|
||||
var manager := _create_manager(64)
|
||||
var missing_animals: Dictionary = manager.create_state_record().to_dictionary()
|
||||
missing_animals.erase("animals")
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(missing_animals) == null,
|
||||
"Current world records should require the authoritative animal array"
|
||||
)
|
||||
var invalid_position := (
|
||||
AnimalStateRecord
|
||||
. from_dictionary(
|
||||
{
|
||||
"schema_version": AnimalStateRecord.SCHEMA_VERSION,
|
||||
"animal_id": "invalid_goat",
|
||||
"display_name": "Invalid",
|
||||
"species_id": String(SimulationIds.SPECIES_GOAT),
|
||||
"position": [NAN, 0.0, 0.0],
|
||||
"hunger": 70.0,
|
||||
"last_fed_tick": AnimalStateRecord.NEVER_FED_TICK,
|
||||
"reserved_by": -1,
|
||||
"enabled": true,
|
||||
"can_npcs_feed": true,
|
||||
"can_player_feed": true,
|
||||
}
|
||||
)
|
||||
)
|
||||
_check(invalid_position == null, "Animal records should reject non-finite positions")
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_relationship_schema_rejection() -> void:
|
||||
var manager := _create_manager(58)
|
||||
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
|
||||
@@ -132,7 +132,7 @@ func _run() -> void:
|
||||
var active_restored := _create_manager(1202)
|
||||
_check(
|
||||
active_restored.restore_state_from_json(active_json),
|
||||
"An active missing-wood need should survive schema-v9 restore"
|
||||
"An active missing-wood need should survive schema-v10 restore"
|
||||
)
|
||||
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
|
||||
_check(
|
||||
|
||||
@@ -77,12 +77,40 @@ func _get_argument_value(prefix: String, default_value: String) -> String:
|
||||
|
||||
|
||||
func _apply_resource_capture_overrides() -> bool:
|
||||
var hide_labels := OS.get_cmdline_user_args().has("--hide-resource-labels")
|
||||
var hide_all_labels := OS.get_cmdline_user_args().has("--hide-debug-labels")
|
||||
var hide_labels := hide_all_labels or OS.get_cmdline_user_args().has("--hide-resource-labels")
|
||||
for resource in ResourceNode.get_all():
|
||||
if hide_labels:
|
||||
resource.set_debug_label_enabled(false)
|
||||
var hide_animal_labels := (
|
||||
hide_all_labels or OS.get_cmdline_user_args().has("--hide-animal-labels")
|
||||
)
|
||||
for animal in AnimalNode.get_all():
|
||||
if hide_animal_labels:
|
||||
animal.set_debug_label_enabled(false)
|
||||
if hide_all_labels:
|
||||
for storage in StorageNode.get_all():
|
||||
storage.set_debug_label_enabled(false)
|
||||
for site in ActivitySite.get_all():
|
||||
site.set_debug_label_enabled(false)
|
||||
|
||||
for argument in OS.get_cmdline_user_args():
|
||||
if argument.begins_with("--animal-hunger="):
|
||||
var animal_override := argument.trim_prefix("--animal-hunger=")
|
||||
var animal_parts := animal_override.split(":", false, 1)
|
||||
if animal_parts.size() != 2 or not animal_parts[1].is_valid_float():
|
||||
push_error("Invalid animal hunger override: %s" % animal_override)
|
||||
return false
|
||||
var animal := AnimalNode.get_by_id(StringName(animal_parts[0]))
|
||||
if animal == null:
|
||||
push_error("Unknown animal hunger override ID: %s" % animal_parts[0])
|
||||
return false
|
||||
var animal_state := AnimalStateRecord.create_from_node(animal)
|
||||
animal_state.set_hunger(animal_parts[1].to_float())
|
||||
if not animal.bind_state(animal_state):
|
||||
push_error("Could not bind capture state for animal: %s" % animal_parts[0])
|
||||
return false
|
||||
continue
|
||||
if not argument.begins_with("--resource-amount="):
|
||||
continue
|
||||
var override_text := argument.trim_prefix("--resource-amount=")
|
||||
|
||||
@@ -59,6 +59,41 @@ func get_resource_index_stats() -> Dictionary:
|
||||
return _resource_index.get_stats()
|
||||
|
||||
|
||||
func get_animal_candidates(action_id: StringName) -> Array[Dictionary]:
|
||||
var candidates: Array[Dictionary] = []
|
||||
for node in AnimalNode.get_all():
|
||||
if not node.supports_action(action_id):
|
||||
continue
|
||||
(
|
||||
candidates
|
||||
. append(
|
||||
{
|
||||
"target_id": String(node.animal_id),
|
||||
"position": node.get_interaction_position(),
|
||||
"display_name": node.display_name,
|
||||
}
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
func get_animal_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[AnimalNode]:
|
||||
var nodes: Array[AnimalNode] = []
|
||||
var maximum_distance_squared := maxf(max_distance, 0.0) * maxf(max_distance, 0.0)
|
||||
for node in AnimalNode.get_all():
|
||||
if origin.distance_squared_to(node.get_interaction_position()) <= maximum_distance_squared:
|
||||
nodes.append(node)
|
||||
nodes.sort_custom(
|
||||
func(first: AnimalNode, second: AnimalNode) -> bool:
|
||||
var first_distance := origin.distance_squared_to(first.get_interaction_position())
|
||||
var second_distance := origin.distance_squared_to(second.get_interaction_position())
|
||||
if not is_equal_approx(first_distance, second_distance):
|
||||
return first_distance < second_distance
|
||||
return String(first.animal_id) < String(second.animal_id)
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary:
|
||||
var best_candidate: Dictionary = {}
|
||||
var best_distance := INF
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
class_name AnimalNode
|
||||
extends Node3D
|
||||
|
||||
signal hunger_changed(animal_id: StringName, hunger: float)
|
||||
signal fed(animal_id: StringName, actor_id: int)
|
||||
signal reservation_changed(animal_id: StringName, agent_id: int)
|
||||
|
||||
static var _all: Array[AnimalNode] = []
|
||||
|
||||
@export var animal_id: StringName
|
||||
@export var display_name := "Animal"
|
||||
@export var species_id: StringName = SimulationIds.SPECIES_GOAT
|
||||
@export_range(0.0, 100.0, 0.5) var initial_hunger := 70.0
|
||||
@export var initial_enabled := true
|
||||
@export var can_npcs_feed := true
|
||||
@export var can_player_feed := true
|
||||
@export var debug_label_enabled := true
|
||||
|
||||
@onready var interaction_point: Marker3D = get_node_or_null("InteractionPoint") as Marker3D
|
||||
|
||||
var state: AnimalStateRecord
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if animal_id.is_empty():
|
||||
push_error("AnimalNode at %s has empty animal_id" % get_path())
|
||||
initial_enabled = false
|
||||
_update_presentation()
|
||||
return
|
||||
var existing := get_by_id(animal_id)
|
||||
if existing != null:
|
||||
push_error(
|
||||
(
|
||||
"Duplicate AnimalNode animal_id '%s' at %s; first registered at %s"
|
||||
% [animal_id, get_path(), existing.get_path()]
|
||||
)
|
||||
)
|
||||
initial_enabled = false
|
||||
_update_presentation()
|
||||
return
|
||||
_all.append(self)
|
||||
add_to_group("animal_nodes")
|
||||
_try_register_with_simulation()
|
||||
_update_presentation()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_all.erase(self)
|
||||
_disconnect_state()
|
||||
state = null
|
||||
|
||||
|
||||
func bind_state(animal_state: AnimalStateRecord) -> bool:
|
||||
if animal_state == null or animal_state.get_animal_id() != animal_id:
|
||||
return false
|
||||
_disconnect_state()
|
||||
state = animal_state
|
||||
if not state.changed.is_connected(_on_state_changed):
|
||||
state.changed.connect(_on_state_changed)
|
||||
if not state.fed.is_connected(_on_state_fed):
|
||||
state.fed.connect(_on_state_fed)
|
||||
global_position = state.get_position()
|
||||
var visual := get_node_or_null("Visual")
|
||||
if visual != null and visual.has_method("reset_transient_feedback"):
|
||||
visual.reset_transient_feedback()
|
||||
_update_presentation()
|
||||
hunger_changed.emit(animal_id, state.get_hunger())
|
||||
return true
|
||||
|
||||
|
||||
func _disconnect_state() -> void:
|
||||
if state == null:
|
||||
return
|
||||
if state.changed.is_connected(_on_state_changed):
|
||||
state.changed.disconnect(_on_state_changed)
|
||||
if state.fed.is_connected(_on_state_fed):
|
||||
state.fed.disconnect(_on_state_fed)
|
||||
|
||||
|
||||
func _try_register_with_simulation() -> void:
|
||||
var managers := get_tree().get_nodes_in_group("simulation_manager")
|
||||
if managers.size() != 1:
|
||||
return
|
||||
var manager := managers[0]
|
||||
if "animal_care" in manager and manager.animal_care != null:
|
||||
manager.animal_care.register_node(self)
|
||||
|
||||
|
||||
func get_interaction_position() -> Vector3:
|
||||
return interaction_point.global_position if interaction_point != null else global_position
|
||||
|
||||
|
||||
func supports_action(action_id: StringName) -> bool:
|
||||
return action_id == SimulationIds.ACTION_FEED_ANIMAL
|
||||
|
||||
|
||||
func get_hunger() -> float:
|
||||
return state.get_hunger() if state != null else initial_hunger
|
||||
|
||||
|
||||
func needs_feed() -> bool:
|
||||
if state != null:
|
||||
return state.needs_feed()
|
||||
return initial_enabled and initial_hunger >= AnimalStateRecord.FEED_THRESHOLD
|
||||
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return state.is_enabled() if state != null else initial_enabled
|
||||
|
||||
|
||||
func _on_state_changed(changed_state: AnimalStateRecord) -> void:
|
||||
if changed_state != state:
|
||||
return
|
||||
if not global_position.is_equal_approx(state.get_position()):
|
||||
global_position = state.get_position()
|
||||
hunger_changed.emit(animal_id, state.get_hunger())
|
||||
reservation_changed.emit(animal_id, state.get_reserved_by())
|
||||
_update_presentation()
|
||||
|
||||
|
||||
func _on_state_fed(fed_state: AnimalStateRecord, actor_id: int) -> void:
|
||||
if fed_state != state:
|
||||
return
|
||||
var visual := get_node_or_null("Visual")
|
||||
if visual != null and visual.has_method("play_fed_response"):
|
||||
visual.play_fed_response()
|
||||
fed.emit(animal_id, actor_id)
|
||||
|
||||
|
||||
func _update_presentation() -> void:
|
||||
var visual := get_node_or_null("Visual")
|
||||
if visual != null:
|
||||
visual.visible = is_enabled()
|
||||
if visual.has_method("set_hungry"):
|
||||
visual.set_hungry(needs_feed())
|
||||
if not has_node("DebugLabel"):
|
||||
return
|
||||
var label := $DebugLabel as Label3D
|
||||
label.visible = debug_label_enabled
|
||||
if not debug_label_enabled:
|
||||
return
|
||||
var text := "%s\n%s · hunger %.0f" % [display_name, animal_id, get_hunger()]
|
||||
if needs_feed():
|
||||
text += "\nHUNGRY"
|
||||
if state != null and state.get_reserved_by() >= 0:
|
||||
text += "\nheld:%d" % state.get_reserved_by()
|
||||
if state == null:
|
||||
text += "\nUNBOUND"
|
||||
label.text = text
|
||||
|
||||
|
||||
func set_debug_label_enabled(is_enabled: bool) -> void:
|
||||
debug_label_enabled = is_enabled
|
||||
_update_presentation()
|
||||
|
||||
|
||||
static func get_by_id(search_id: StringName) -> AnimalNode:
|
||||
for node in _all:
|
||||
if node.animal_id == search_id:
|
||||
return node
|
||||
return null
|
||||
|
||||
|
||||
static func get_all() -> Array[AnimalNode]:
|
||||
return _all.duplicate()
|
||||
@@ -0,0 +1 @@
|
||||
uid://6s3isttop6a6
|
||||
@@ -359,6 +359,10 @@ func _apply_debug_overlay_visibility() -> void:
|
||||
if node.has_method("set_debug_label_enabled"):
|
||||
node.set_debug_label_enabled(debug_overlay_visible)
|
||||
|
||||
for node in AnimalNode.get_all():
|
||||
if node.has_method("set_debug_label_enabled"):
|
||||
node.set_debug_label_enabled(debug_overlay_visible)
|
||||
|
||||
for node in StorageNode.get_all():
|
||||
if node.has_method("set_debug_label_enabled"):
|
||||
node.set_debug_label_enabled(debug_overlay_visible)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
extends Node3D
|
||||
|
||||
const HUNGER_CUE_BASE_Y := 2.05
|
||||
const FED_RESPONSE_BASE_Y := 1.8
|
||||
|
||||
@onready var head_root: Node3D = $HeadRoot
|
||||
@onready var tail_root: Node3D = $TailRoot
|
||||
@onready var hunger_cue_root: Node3D = $HungerCueRoot
|
||||
@onready var fed_response_root: Node3D = $FedResponseRoot
|
||||
|
||||
var idle_phase := 0.0
|
||||
var is_hungry := false
|
||||
var fed_tween: Tween
|
||||
var head_base_position: Vector3
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
head_base_position = head_root.position
|
||||
reset_transient_feedback()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
idle_phase = fmod(idle_phase + delta, TAU)
|
||||
var breath := sin(idle_phase * 1.7) * 0.025
|
||||
var hungry_drop := -0.12 if is_hungry else 0.0
|
||||
head_root.position.y = head_base_position.y + breath + hungry_drop
|
||||
head_root.rotation_degrees.x = (
|
||||
8.0 + sin(idle_phase * 1.2) * 2.0 if is_hungry else -3.0 + sin(idle_phase * 1.4) * 2.5
|
||||
)
|
||||
tail_root.rotation_degrees.z = sin(idle_phase * 3.1) * (4.0 if is_hungry else 13.0)
|
||||
if hunger_cue_root.visible:
|
||||
hunger_cue_root.position.y = HUNGER_CUE_BASE_Y + sin(idle_phase * 2.2) * 0.07
|
||||
|
||||
|
||||
func set_hungry(value: bool) -> void:
|
||||
is_hungry = value
|
||||
hunger_cue_root.visible = value
|
||||
|
||||
|
||||
func reset_transient_feedback() -> void:
|
||||
if fed_tween != null:
|
||||
fed_tween.kill()
|
||||
fed_tween = null
|
||||
fed_response_root.visible = false
|
||||
fed_response_root.position.y = FED_RESPONSE_BASE_Y
|
||||
fed_response_root.scale = Vector3(0.2, 0.2, 0.2)
|
||||
|
||||
|
||||
func play_fed_response() -> void:
|
||||
reset_transient_feedback()
|
||||
fed_response_root.visible = true
|
||||
fed_tween = create_tween()
|
||||
fed_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
fed_tween.tween_property(fed_response_root, "scale", Vector3.ONE, 0.34)
|
||||
fed_tween.parallel().tween_property(
|
||||
fed_response_root, "position:y", FED_RESPONSE_BASE_Y + 0.22, 0.34
|
||||
)
|
||||
fed_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
||||
fed_tween.tween_interval(0.7)
|
||||
fed_tween.tween_property(fed_response_root, "scale", Vector3(0.2, 0.2, 0.2), 0.42)
|
||||
fed_tween.finished.connect(_on_fed_response_finished)
|
||||
|
||||
|
||||
func _on_fed_response_finished() -> void:
|
||||
fed_response_root.visible = false
|
||||
fed_tween = null
|
||||
@@ -0,0 +1 @@
|
||||
uid://byx6pyisksbtg
|
||||
@@ -0,0 +1,329 @@
|
||||
[gd_scene load_steps=26 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://world/animals/AnimalNode.gd" id="1_animal"]
|
||||
[ext_resource type="Script" path="res://world/jajce/CozyGoat.gd" id="2_visual"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_wool"]
|
||||
albedo_color = Color(0.88, 0.82, 0.67, 1)
|
||||
roughness = 0.96
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_face"]
|
||||
albedo_color = Color(0.58, 0.43, 0.29, 1)
|
||||
roughness = 0.92
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_dark"]
|
||||
albedo_color = Color(0.13, 0.09, 0.065, 1)
|
||||
roughness = 0.9
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_horn"]
|
||||
albedo_color = Color(0.78, 0.68, 0.5, 1)
|
||||
roughness = 0.86
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_collar"]
|
||||
albedo_color = Color(0.12, 0.5, 0.5, 1)
|
||||
roughness = 0.72
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_wood"]
|
||||
albedo_color = Color(0.35, 0.19, 0.09, 1)
|
||||
roughness = 0.96
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_hunger"]
|
||||
transparency = 1
|
||||
shading_mode = 0
|
||||
albedo_color = Color(0.98, 0.62, 0.22, 0.9)
|
||||
emission_enabled = true
|
||||
emission = Color(0.92, 0.36, 0.08, 1)
|
||||
emission_energy_multiplier = 0.32
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_fed"]
|
||||
transparency = 1
|
||||
shading_mode = 0
|
||||
albedo_color = Color(0.72, 0.92, 0.5, 0.92)
|
||||
emission_enabled = true
|
||||
emission = Color(0.45, 0.78, 0.28, 1)
|
||||
emission_energy_multiplier = 0.38
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="Mesh_body"]
|
||||
material = SubResource("Material_wool")
|
||||
radius = 0.48
|
||||
height = 1.55
|
||||
radial_segments = 12
|
||||
rings = 4
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_fluff"]
|
||||
material = SubResource("Material_wool")
|
||||
radius = 0.42
|
||||
height = 0.72
|
||||
radial_segments = 12
|
||||
rings = 6
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="Mesh_leg"]
|
||||
material = SubResource("Material_face")
|
||||
radius = 0.105
|
||||
height = 0.62
|
||||
radial_segments = 8
|
||||
rings = 3
|
||||
|
||||
[sub_resource type="BoxMesh" id="Mesh_hoof"]
|
||||
material = SubResource("Material_dark")
|
||||
size = Vector3(0.2, 0.13, 0.28)
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_head"]
|
||||
material = SubResource("Material_face")
|
||||
radius = 0.34
|
||||
height = 0.58
|
||||
radial_segments = 12
|
||||
rings = 6
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_snout"]
|
||||
material = SubResource("Material_wool")
|
||||
radius = 0.21
|
||||
height = 0.32
|
||||
radial_segments = 10
|
||||
rings = 5
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_ear"]
|
||||
material = SubResource("Material_face")
|
||||
radius = 0.24
|
||||
height = 0.42
|
||||
radial_segments = 10
|
||||
rings = 5
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_eye"]
|
||||
material = SubResource("Material_dark")
|
||||
radius = 0.045
|
||||
height = 0.075
|
||||
radial_segments = 8
|
||||
rings = 4
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_horn"]
|
||||
material = SubResource("Material_horn")
|
||||
top_radius = 0.035
|
||||
bottom_radius = 0.09
|
||||
height = 0.42
|
||||
radial_segments = 8
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_beard"]
|
||||
material = SubResource("Material_wool")
|
||||
top_radius = 0.015
|
||||
bottom_radius = 0.11
|
||||
height = 0.34
|
||||
radial_segments = 8
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_tail"]
|
||||
material = SubResource("Material_wool")
|
||||
top_radius = 0.07
|
||||
bottom_radius = 0.12
|
||||
height = 0.42
|
||||
radial_segments = 8
|
||||
|
||||
[sub_resource type="TorusMesh" id="Mesh_collar"]
|
||||
material = SubResource("Material_collar")
|
||||
inner_radius = 0.27
|
||||
outer_radius = 0.32
|
||||
rings = 14
|
||||
ring_segments = 8
|
||||
|
||||
[sub_resource type="CylinderMesh" id="Mesh_bowl"]
|
||||
material = SubResource("Material_wood")
|
||||
top_radius = 0.36
|
||||
bottom_radius = 0.28
|
||||
height = 0.13
|
||||
radial_segments = 12
|
||||
|
||||
[sub_resource type="TorusMesh" id="Mesh_hunger_bowl"]
|
||||
material = SubResource("Material_hunger")
|
||||
inner_radius = 0.18
|
||||
outer_radius = 0.27
|
||||
rings = 14
|
||||
ring_segments = 8
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_hunger_food"]
|
||||
material = SubResource("Material_hunger")
|
||||
radius = 0.09
|
||||
height = 0.14
|
||||
radial_segments = 8
|
||||
rings = 4
|
||||
|
||||
[sub_resource type="TorusMesh" id="Mesh_fed_ring"]
|
||||
material = SubResource("Material_fed")
|
||||
inner_radius = 0.32
|
||||
outer_radius = 0.42
|
||||
rings = 16
|
||||
ring_segments = 8
|
||||
|
||||
[sub_resource type="SphereMesh" id="Mesh_fed_mote"]
|
||||
material = SubResource("Material_fed")
|
||||
radius = 0.075
|
||||
height = 0.13
|
||||
radial_segments = 8
|
||||
rings = 4
|
||||
|
||||
[node name="CozyGoat" type="Node3D"]
|
||||
script = ExtResource("1_animal")
|
||||
animal_id = &"goat_dunja"
|
||||
display_name = "Dunja"
|
||||
species_id = &"goat"
|
||||
initial_hunger = 72.0
|
||||
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
script = ExtResource("2_visual")
|
||||
|
||||
[node name="Body" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0, 0.95, 0)
|
||||
rotation_degrees = Vector3(90, 0, 0)
|
||||
mesh = SubResource("Mesh_body")
|
||||
|
||||
[node name="FluffFront" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0, 1.02, 0.42)
|
||||
scale = Vector3(1.08, 1.05, 1)
|
||||
mesh = SubResource("Mesh_fluff")
|
||||
|
||||
[node name="FluffBack" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0, 1.02, -0.42)
|
||||
scale = Vector3(1.08, 1.05, 1)
|
||||
mesh = SubResource("Mesh_fluff")
|
||||
|
||||
[node name="LegFrontLeft" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(-0.3, 0.43, 0.48)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontLeft"]
|
||||
position = Vector3(0, -0.29, 0.035)
|
||||
mesh = SubResource("Mesh_hoof")
|
||||
|
||||
[node name="LegFrontRight" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0.3, 0.43, 0.48)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontRight"]
|
||||
position = Vector3(0, -0.29, 0.035)
|
||||
mesh = SubResource("Mesh_hoof")
|
||||
|
||||
[node name="LegBackLeft" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(-0.3, 0.43, -0.48)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackLeft"]
|
||||
position = Vector3(0, -0.29, 0.035)
|
||||
mesh = SubResource("Mesh_hoof")
|
||||
|
||||
[node name="LegBackRight" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0.3, 0.43, -0.48)
|
||||
mesh = SubResource("Mesh_leg")
|
||||
|
||||
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackRight"]
|
||||
position = Vector3(0, -0.29, 0.035)
|
||||
mesh = SubResource("Mesh_hoof")
|
||||
|
||||
[node name="HeadRoot" type="Node3D" parent="Visual"]
|
||||
position = Vector3(0, 1.28, 0.88)
|
||||
rotation_degrees = Vector3(-3, 0, 0)
|
||||
|
||||
[node name="Head" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
mesh = SubResource("Mesh_head")
|
||||
|
||||
[node name="Snout" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0, -0.08, 0.31)
|
||||
scale = Vector3(1.05, 0.72, 0.9)
|
||||
mesh = SubResource("Mesh_snout")
|
||||
|
||||
[node name="EyeLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(-0.18, 0.08, 0.285)
|
||||
mesh = SubResource("Mesh_eye")
|
||||
|
||||
[node name="EyeRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0.18, 0.08, 0.285)
|
||||
mesh = SubResource("Mesh_eye")
|
||||
|
||||
[node name="EarLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(-0.35, 0.13, 0)
|
||||
rotation_degrees = Vector3(8, 0, 67)
|
||||
scale = Vector3(0.82, 0.28, 1)
|
||||
mesh = SubResource("Mesh_ear")
|
||||
|
||||
[node name="EarRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0.35, 0.13, 0)
|
||||
rotation_degrees = Vector3(8, 0, -67)
|
||||
scale = Vector3(0.82, 0.28, 1)
|
||||
mesh = SubResource("Mesh_ear")
|
||||
|
||||
[node name="HornLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(-0.17, 0.34, -0.06)
|
||||
rotation_degrees = Vector3(-24, 0, -20)
|
||||
mesh = SubResource("Mesh_horn")
|
||||
|
||||
[node name="HornRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0.17, 0.34, -0.06)
|
||||
rotation_degrees = Vector3(-24, 0, 20)
|
||||
mesh = SubResource("Mesh_horn")
|
||||
|
||||
[node name="Beard" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0, -0.35, 0.08)
|
||||
mesh = SubResource("Mesh_beard")
|
||||
|
||||
[node name="Collar" type="MeshInstance3D" parent="Visual/HeadRoot"]
|
||||
position = Vector3(0, -0.31, -0.16)
|
||||
rotation_degrees = Vector3(90, 0, 0)
|
||||
scale = Vector3(0.85, 0.85, 0.85)
|
||||
mesh = SubResource("Mesh_collar")
|
||||
|
||||
[node name="TailRoot" type="Node3D" parent="Visual"]
|
||||
position = Vector3(0, 1.16, -0.92)
|
||||
rotation_degrees = Vector3(60, 0, 0)
|
||||
|
||||
[node name="Tail" type="MeshInstance3D" parent="Visual/TailRoot"]
|
||||
position = Vector3(0, 0.18, 0)
|
||||
mesh = SubResource("Mesh_tail")
|
||||
|
||||
[node name="FeedBowl" type="MeshInstance3D" parent="Visual"]
|
||||
position = Vector3(0.72, 0.08, 1.43)
|
||||
mesh = SubResource("Mesh_bowl")
|
||||
|
||||
[node name="HungerCueRoot" type="Node3D" parent="Visual"]
|
||||
position = Vector3(0, 2.05, 0.18)
|
||||
|
||||
[node name="EmptyBowl" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
|
||||
rotation_degrees = Vector3(72, 0, 0)
|
||||
scale = Vector3(1.1, 0.72, 1.1)
|
||||
mesh = SubResource("Mesh_hunger_bowl")
|
||||
|
||||
[node name="FoodMoteLeft" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
|
||||
position = Vector3(-0.13, 0.22, 0)
|
||||
mesh = SubResource("Mesh_hunger_food")
|
||||
|
||||
[node name="FoodMoteRight" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
|
||||
position = Vector3(0.13, 0.29, 0)
|
||||
scale = Vector3(0.82, 0.82, 0.82)
|
||||
mesh = SubResource("Mesh_hunger_food")
|
||||
|
||||
[node name="FedResponseRoot" type="Node3D" parent="Visual"]
|
||||
visible = false
|
||||
position = Vector3(0, 1.8, 0.12)
|
||||
scale = Vector3(0.2, 0.2, 0.2)
|
||||
|
||||
[node name="Halo" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
|
||||
rotation_degrees = Vector3(74, 0, 0)
|
||||
mesh = SubResource("Mesh_fed_ring")
|
||||
|
||||
[node name="MoteLeft" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
|
||||
position = Vector3(-0.34, 0.18, 0)
|
||||
mesh = SubResource("Mesh_fed_mote")
|
||||
|
||||
[node name="MoteRight" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
|
||||
position = Vector3(0.34, 0.28, 0)
|
||||
scale = Vector3(0.82, 0.82, 0.82)
|
||||
mesh = SubResource("Mesh_fed_mote")
|
||||
|
||||
[node name="InteractionPoint" type="Marker3D" parent="."]
|
||||
position = Vector3(0, 0, 2.05)
|
||||
|
||||
[node name="DebugLabel" type="Label3D" parent="."]
|
||||
position = Vector3(0, 2.55, 0)
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
font_size = 22
|
||||
outline_size = 5
|
||||
text = "Dunja"
|
||||
modulate = Color(1, 0.94, 0.78, 1)
|
||||
pixel_size = 0.006
|
||||
@@ -0,0 +1,16 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="1_world"]
|
||||
[ext_resource type="Script" path="res://world/jajce/beauty_camera.gd" id="2_camera"]
|
||||
|
||||
[node name="JajceGoatLookdev" type="Node3D"]
|
||||
|
||||
[node name="JajceWorld" parent="." instance=ExtResource("1_world")]
|
||||
|
||||
[node name="GoatCamera" type="Camera3D" parent="."]
|
||||
position = Vector3(0.2, 2.9, -7)
|
||||
current = true
|
||||
fov = 32.0
|
||||
script = ExtResource("2_camera")
|
||||
focus_point = Vector3(-4, 1.15, -11.5)
|
||||
animate_orbit = false
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=59 format=3]
|
||||
[gd_scene load_steps=60 format=3]
|
||||
|
||||
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
|
||||
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
|
||||
@@ -27,6 +27,7 @@
|
||||
[ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"]
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"]
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"]
|
||||
[ext_resource type="PackedScene" path="res://world/jajce/CozyGoat.tscn" id="28_goat"]
|
||||
|
||||
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
|
||||
_shader_parameters = {
|
||||
@@ -458,6 +459,12 @@ phase = 4.4
|
||||
|
||||
[node name="WorldObjects" type="Node3D" parent="."]
|
||||
|
||||
[node name="Animals" type="Node3D" parent="WorldObjects"]
|
||||
|
||||
[node name="Dunja" parent="WorldObjects/Animals" instance=ExtResource("28_goat")]
|
||||
position = Vector3(-4, 0, -11.5)
|
||||
rotation_degrees = Vector3(0, -18, 0)
|
||||
|
||||
[node name="ResourceNodes" type="Node3D" parent="WorldObjects"]
|
||||
|
||||
[node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
|
||||
|
||||
@@ -21,7 +21,7 @@ func _initialize_world_presentation() -> void:
|
||||
groups_to_snap.append(child)
|
||||
if has_node("WorldObjects"):
|
||||
var wo := $WorldObjects
|
||||
for group_name in ["ResourceNodes", "StorageSites", "ActivitySites"]:
|
||||
for group_name in ["Animals", "ResourceNodes", "StorageSites", "ActivitySites"]:
|
||||
if wo.has_node(group_name):
|
||||
groups_to_snap.append_array(wo.get_node(group_name).get_children())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user