Compare commits

..

3 Commits

Author SHA1 Message Date
admin 4604dc6d5a feat: add NPC schedules with sleep, meal, and work periods
Add ACTION_SLEEP with home-position targeting and energy restoration. Embed schedule periods (SLEEP/MEAL/WORK/DISCRETIONARY) in ActionSelectionSystem with TimeOfDay from SimulationClock. NPCs sleep at night when energy is low, eat during meal periods when hungry, and work/discretionary the rest of the cycle. Home position stored per NPC and serialized through NPCStateRecord with backward-compatible fallback. Cycle duration persisted through save/restore. Includes headless test for sleep period, work period, meal hunger, energy restoration, serialization round-trip, and home targeting without world adapter.
2026-07-08 17:44:39 +02:00
admin 6fdb9ba50f feat: expand resource discovery to 18 finite ResourceNodes
Add 6 new resources: farm_crop_01 (farming), berry_bush_river_02 (river bank), berry_patch_mill_01 (mill foraging), tree_forest_grove_01/02 (deep forest), wood_pile_mill_01 (mill stockpile). Extend tests to 18 resources, >=9 food, >=9 wood, >=6 berry, farming context assertion.
2026-07-08 17:21:05 +02:00
admin 4054e2c0dd feat: strengthen water and foreground silhouettes
- Upgrade river shader with multi-layer UV flow, fresnel edges, foam highlights, and specular sparkle
- Upgrade waterfall shader with dual-band scrolling, distinct foam contrast, and rim transparency
- Widen river (8m) and waterfall (8m) for greater visual presence
- Increase waterfall mist particles to 72 with wider spread and longer lifetime
- Add fortress crenellations (10), three turrets, and prominent ridge banner for readable skyline silhouette
- Remove duplicate banner nodes from JajceWorld instance (now owned by FortressBlockout scene)
- Update BUILD_IN_PUBLIC_PLAN and LEARNING_ROADMAP to reflect completion
2026-07-08 17:17:34 +02:00
23 changed files with 589 additions and 69 deletions
+13 -2
View File
@@ -644,10 +644,21 @@ Completed:
study desk, and rest bench now have compact presentation props that remain study desk, and rest bench now have compact presentation props that remain
readable in the runtime cinematic frame. readable in the runtime cinematic frame.
Completed:
15. Water and foreground silhouette strengthening: river/waterfall shaders
now include multi-layer UV flow, fresnel edges, foam highlights, sparkle
specular, and better color depth; the waterfall has distinct foam bands
and rim transparency; mist particle count and spread increased; the river
and waterfall widened; the fortress ridge landmark now has crenellations,
a dedicated banner pole with larger banner, and three turrets for a more
readable silhouette against the sky.
Next: Next:
1. Strengthen water and foreground silhouettes without adding new simulation 1. Continue growing resource discovery with finite ResourceNode instances
mechanics. placed in meaningful contexts (from LEARNING_ROADMAP). See the roadmap
for the next systems priority.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+4
View File
@@ -689,6 +689,10 @@ Recently completed:
authored path strips that reveal the active village task loop. authored path strips that reveal the active village task loop.
- Landmark and work-site silhouette props make the ridge landmark, pantry, - Landmark and work-site silhouette props make the ridge landmark, pantry,
guard, study, and rest sites easier to identify without debug labels. guard, study, and rest sites easier to identify without debug labels.
- Water and foreground silhouettes strengthened: river/waterfall shaders with
multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river
and waterfall; fortress with crenellations, three turrets, and prominent
ridge banner.
This order strengthens the simulation while regularly producing visible This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+2
View File
@@ -16,6 +16,7 @@ var intelligence: float
var current_task: StringName = SimulationIds.ACTION_IDLE var current_task: StringName = SimulationIds.ACTION_IDLE
var task_state: StringName = TASK_STATE_IDLE var task_state: StringName = TASK_STATE_IDLE
var position: Vector3 var position: Vector3
var home_position: Vector3
var is_starving := false var is_starving := false
var starvation_ticks := 0 var starvation_ticks := 0
@@ -56,6 +57,7 @@ func _init(
position = Vector3( position = Vector3(
random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0) random_source.randf_range(-8.0, 8.0), 0.0, random_source.randf_range(-8.0, 8.0)
) )
home_position = position
func die_from_starvation() -> void: func die_from_starvation() -> void:
+6
View File
@@ -4,6 +4,7 @@ extends RefCounted
var tick_interval: float var tick_interval: float
var accumulator := 0.0 var accumulator := 0.0
var elapsed_ticks := 0 var elapsed_ticks := 0
var cycle_duration_seconds := 240.0
func _init(interval: float = 1.0) -> void: func _init(interval: float = 1.0) -> void:
@@ -23,3 +24,8 @@ func advance(delta: float) -> int:
func reset() -> void: func reset() -> void:
accumulator = 0.0 accumulator = 0.0
elapsed_ticks = 0 elapsed_ticks = 0
func time_of_day() -> float:
var total_seconds := elapsed_ticks * tick_interval + accumulator
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
+19 -5
View File
@@ -15,6 +15,7 @@ var village := SimVillage.new()
var npcs: Array[SimNPC] = [] var npcs: Array[SimNPC] = []
@export var tick_interval := 1.2 @export var tick_interval := 1.2
@export var simulation_seed: int = 1337 @export var simulation_seed: int = 1337
@export var cycle_duration_seconds := 240.0
@export var debug_logs := true @export var debug_logs := true
@export var active_world_adapter: Node @export var active_world_adapter: Node
@@ -42,6 +43,7 @@ func _ready() -> void:
set_process(false) set_process(false)
return return
clock = SimulationClock.new(tick_interval) clock = SimulationClock.new(tick_interval)
clock.cycle_duration_seconds = cycle_duration_seconds
village.debug_logs = debug_logs village.debug_logs = debug_logs
_initialize_storage() _initialize_storage()
village.update_modifiers() village.update_modifiers()
@@ -112,7 +114,7 @@ func simulate_tick() -> void:
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE] and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE] and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
): ):
var selection := action_selector.select_action(npc, village) var selection := action_selector.select_action(npc, village, clock.time_of_day())
if selection != null: if selection != null:
latest_decisions[npc.id] = selection latest_decisions[npc.id] = selection
npc_decision_recorded.emit(npc, selection) npc_decision_recorded.emit(npc, selection)
@@ -195,6 +197,11 @@ func simulate_tick() -> void:
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0) withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
elif completed_task == SimulationIds.ACTION_EAT: elif completed_task == SimulationIds.ACTION_EAT:
consume_npc_food(npc) consume_npc_food(npc)
elif completed_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position
if debug_logs:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
elif ( elif (
completed_task completed_task
not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD] not in [SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD]
@@ -354,14 +361,19 @@ func notify_npc_target_unavailable(npc_id: int) -> void:
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool: func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
for npc in npcs: for npc in npcs:
if npc.id != npc_id: if npc.id != npc_id:
continue continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING: if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false return false
if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.travel_target_position = npc.home_position
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
var result := target_resolver.resolve(npc, origin, self, active_world_adapter) var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
if result.is_empty(): if result.is_empty():
notify_npc_target_unavailable(npc.id) notify_npc_target_unavailable(npc.id)
@@ -738,7 +750,8 @@ func create_state_record() -> SimulationStateRecord:
"clock_accumulator": clock.accumulator, "clock_accumulator": clock.accumulator,
"clock_elapsed_ticks": clock.elapsed_ticks, "clock_elapsed_ticks": clock.elapsed_ticks,
"wander_random_streams": wander_streams, "wander_random_streams": wander_streams,
"next_event_id": next_event_id "next_event_id": next_event_id,
"cycle_duration_seconds": clock.cycle_duration_seconds
} }
record.village = VillageStateRecord.capture(village) record.village = VillageStateRecord.capture(village)
for npc in npcs: for npc in npcs:
@@ -782,6 +795,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
clock = SimulationClock.new(tick_interval) clock = SimulationClock.new(tick_interval)
clock.accumulator = float(record.simulation["clock_accumulator"]) clock.accumulator = float(record.simulation["clock_accumulator"])
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
village = record.village.restore(debug_logs) village = record.village.restore(debug_logs)
storage_states.clear() storage_states.clear()
for storage_record in record.storages: for storage_record in record.storages:
+3 -1
View File
@@ -22,7 +22,9 @@ func _update_needs(npc: SimNPC, village: SimVillage) -> void:
SimNPC.TASK_STATE_TRAVELING: SimNPC.TASK_STATE_TRAVELING:
npc.energy -= 2.0 npc.energy -= 2.0
SimNPC.TASK_STATE_WORKING: SimNPC.TASK_STATE_WORKING:
if npc.current_task != SimulationIds.ACTION_REST: if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 5.0, 100.0)
elif npc.current_task != SimulationIds.ACTION_REST:
npc.energy -= 3.0 npc.energy -= 3.0
SimNPC.TASK_STATE_COMPLETE: SimNPC.TASK_STATE_COMPLETE:
npc.energy -= 0.25 npc.energy -= 0.25
+75 -1
View File
@@ -1,10 +1,56 @@
class_name ActionSelectionSystem class_name ActionSelectionSystem
extends RefCounted extends RefCounted
enum SchedulePeriod {
WORK,
SLEEP,
MEAL,
DISCRETIONARY
}
func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: const SLEEP_BEGIN := 0.88
const SLEEP_END := 0.15
const BREAKFAST_BEGIN := 0.25
const BREAKFAST_END := 0.35
const DINNER_BEGIN := 0.7
const DINNER_END := 0.8
const WORK_BEGIN := 0.28
const WORK_END := 0.85
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5) -> ActionSelectionResult:
if npc.is_dead: if npc.is_dead:
return null return null
var period := _get_period(time_of_day)
if period == SchedulePeriod.SLEEP:
if npc.energy < 60.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_SLEEP, -1.0, "Night-time sleep; energy is low"
)
if npc.hunger > 75.0 and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
)
if period == SchedulePeriod.MEAL:
if npc.hunger > 50.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Meal-time hunger; eating from inventory"
)
if village.food > 0:
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Meal-time; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
)
if npc.is_starving: if npc.is_starving:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new( return ActionSelectionResult.new(
@@ -37,6 +83,14 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
) )
if period == SchedulePeriod.DISCRETIONARY:
var roll := npc.random_source.randf()
if roll < 0.3:
return ActionSelectionResult.new(
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
)
return _choose_best_work_action(npc, village) return _choose_best_work_action(npc, village)
@@ -129,3 +183,23 @@ func _calculate_score(
if npc.debug_logs: if npc.debug_logs:
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score) print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
return score return score
static func _get_period(time_of_day: float) -> int:
if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END):
return SchedulePeriod.SLEEP
if _in_range(time_of_day, BREAKFAST_BEGIN, BREAKFAST_END):
return SchedulePeriod.MEAL
if _in_range(time_of_day, DINNER_BEGIN, DINNER_END):
return SchedulePeriod.MEAL
if _in_range(time_of_day, WORK_BEGIN, WORK_END):
return SchedulePeriod.WORK
return SchedulePeriod.DISCRETIONARY
static func _in_range(value: float, begin: float, end: float) -> bool:
return value >= begin and value <= end
static func _in_wrap_range(value: float, begin: float, end: float) -> bool:
return value >= begin or value <= end
@@ -10,7 +10,8 @@ const ACTION_PATHS := [
"res://simulation/definitions/actions/rest.tres", "res://simulation/definitions/actions/rest.tres",
"res://simulation/definitions/actions/wander.tres", "res://simulation/definitions/actions/wander.tres",
"res://simulation/definitions/actions/deposit_food.tres", "res://simulation/definitions/actions/deposit_food.tres",
"res://simulation/definitions/actions/withdraw_food.tres" "res://simulation/definitions/actions/withdraw_food.tres",
"res://simulation/definitions/actions/sleep.tres"
] ]
const PROFESSION_PATHS := [ const PROFESSION_PATHS := [
+1
View File
@@ -3,6 +3,7 @@ extends RefCounted
const ACTION_IDLE := &"idle" const ACTION_IDLE := &"idle"
const ACTION_DEAD := &"dead" const ACTION_DEAD := &"dead"
const ACTION_SLEEP := &"sleep"
const ACTION_GATHER_FOOD := &"gather_food" const ACTION_GATHER_FOOD := &"gather_food"
const ACTION_GATHER_WOOD := &"gather_wood" const ACTION_GATHER_WOOD := &"gather_wood"
const ACTION_PATROL := &"patrol" const ACTION_PATROL := &"patrol"
+10
View File
@@ -0,0 +1,10 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
action_id = &"sleep"
display_name = "Sleep"
default_duration = 8.0
target_type = &"free"
+5
View File
@@ -26,6 +26,7 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"current_task": String(npc.current_task), "current_task": String(npc.current_task),
"task_state": String(npc.task_state), "task_state": String(npc.task_state),
"position": [npc.position.x, npc.position.y, npc.position.z], "position": [npc.position.x, npc.position.y, npc.position.z],
"home_position": [npc.home_position.x, npc.home_position.y, npc.home_position.z],
"is_starving": npc.is_starving, "is_starving": npc.is_starving,
"starvation_ticks": npc.starvation_ticks, "starvation_ticks": npc.starvation_ticks,
"starvation_death_threshold": npc.starvation_death_threshold, "starvation_death_threshold": npc.starvation_death_threshold,
@@ -137,6 +138,10 @@ func restore(debug_logs: bool) -> SimNPC:
npc.position = Vector3( npc.position = Vector3(
float(saved_position[0]), float(saved_position[1]), float(saved_position[2]) float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
) )
var saved_home: Array = data.get("home_position", saved_position)
npc.home_position = Vector3(
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
)
npc.is_starving = bool(data["is_starving"]) npc.is_starving = bool(data["is_starving"])
npc.starvation_ticks = int(data["starvation_ticks"]) npc.starvation_ticks = int(data["starvation_ticks"])
npc.starvation_death_threshold = int(data["starvation_death_threshold"]) npc.starvation_death_threshold = int(data["starvation_death_threshold"])
+1
View File
@@ -12,6 +12,7 @@ func _run() -> void:
manager.debug_logs = false manager.debug_logs = false
root.add_child(manager) root.add_child(manager)
manager.set_process(false) manager.set_process(false)
manager.clock.elapsed_ticks = 100
var pantry: StorageStateRecord = manager.get_pantry() var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0) pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
+4 -4
View File
@@ -53,10 +53,10 @@ func _run() -> void:
"Runtime should not retain duplicate flat-world objects" "Runtime should not retain duplicate flat-world objects"
) )
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes") var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
_check(resource_root.get_child_count() == 12, "Runtime should contain twelve Jajce ResourceNodes") _check(resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes")
_check( _check(
simulation_manager.resource_states.size() == 12, simulation_manager.resource_states.size() == 18,
"Simulation authority should bind all twelve Jajce resources" "Simulation authority should bind all eighteen Jajce resources"
) )
var village_root := main_scene.get_node("JajceWorld/VillageRoot") var village_root := main_scene.get_node("JajceWorld/VillageRoot")
var path_strips := 0 var path_strips := 0
@@ -156,7 +156,7 @@ func _run() -> void:
) )
if failures.is_empty(): if failures.is_empty():
print("[TEST] Jajce runtime passed: 6 NPCs, 12 resources, typed sites") print("[TEST] Jajce runtime passed: 6 NPCs, 18 resources, typed sites")
quit(0) quit(0)
return return
+14 -4
View File
@@ -124,13 +124,19 @@ func _run() -> void:
"animal_camp_river_01", "animal_camp_river_01",
"berry_bush_01", "berry_bush_01",
"berry_bush_02", "berry_bush_02",
"berry_bush_river_02",
"berry_patch_mill_01",
"berry_patch_river_01", "berry_patch_river_01",
"berry_patch_south_01", "berry_patch_south_01",
"farm_crop_01",
"tree_01", "tree_01",
"tree_02", "tree_02",
"tree_forest_grove_01",
"tree_forest_grove_02",
"tree_north_outskirts_01", "tree_north_outskirts_01",
"tree_river_resource_01", "tree_river_resource_01",
"tree_south_edge_01", "tree_south_edge_01",
"wood_pile_mill_01",
"wood_pile_village_01" "wood_pile_village_01"
], ],
"Jajce resources should preserve all stable and discoverable IDs" "Jajce resources should preserve all stable and discoverable IDs"
@@ -142,6 +148,7 @@ func _run() -> void:
var berry_context_count := 0 var berry_context_count := 0
var village_context_count := 0 var village_context_count := 0
var outskirt_context_count := 0 var outskirt_context_count := 0
var farming_context_count := 0
for resource in resources: for resource in resources:
var node := resource as ResourceNode var node := resource as ResourceNode
var id := String(node.node_id) var id := String(node.node_id)
@@ -157,6 +164,8 @@ func _run() -> void:
village_context_count += 1 village_context_count += 1
if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"): if id.contains("_outskirts_") or id.contains("_edge_") or id.contains("_river_"):
outskirt_context_count += 1 outskirt_context_count += 1
if id.begins_with("farm_"):
farming_context_count += 1
metadata_valid = ( metadata_valid = (
metadata_valid metadata_valid
and node.comfort_distance > 0.0 and node.comfort_distance > 0.0
@@ -164,12 +173,13 @@ func _run() -> void:
and node.safety_risk <= 1.0 and node.safety_risk <= 1.0
) )
_check(metadata_valid, "Jajce resources should define discovery metadata") _check(metadata_valid, "Jajce resources should define discovery metadata")
_check(food_count >= 6, "Jajce should expose at least six finite food resources") _check(food_count >= 9, "Jajce should expose at least nine finite food resources")
_check(wood_count >= 6, "Jajce should expose at least six finite wood resources") _check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts") _check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
_check(berry_context_count >= 4, "Jajce food discovery should include berry contexts") _check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
_check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile") _check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile")
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts") _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
@@ -235,7 +245,7 @@ func _run() -> void:
adapter.pantry_storage = pantry adapter.pantry_storage = pantry
root.add_child(adapter) root.add_child(adapter)
var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD) var food_candidates := adapter.get_resource_candidates(SimulationIds.ACTION_GATHER_FOOD)
_check(food_candidates.size() >= 6, "Adapter should expose all authored food candidates") _check(food_candidates.size() >= 9, "Adapter should expose all authored food candidates")
var candidate_metadata_valid := true var candidate_metadata_valid := true
for candidate in food_candidates: for candidate in food_candidates:
candidate_metadata_valid = ( candidate_metadata_valid = (
+186
View File
@@ -0,0 +1,186 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_sleep_period_at_night()
_test_work_period_during_day()
_test_meal_period_hunger()
_test_sleep_restores_energy()
_test_schedule_serialization()
_test_sleep_targets_home()
if failures.is_empty():
print("[TEST] NPC schedule passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
root.add_child(manager)
manager.set_process(false)
return manager
func _test_sleep_period_at_night() -> void:
var manager := _create_manager(100)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.hunger = 20.0
npc.energy = 50.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, manager.clock.time_of_day()
)
_check(selection != null, "Night-time should produce an action selection")
_check(
selection.action_id == SimulationIds.ACTION_SLEEP,
"Low-energy NPC should choose SLEEP during night period"
)
_check(
selection.reason.to_lower().contains("night"),
"Sleep selection reason should reference night-time"
)
manager.free()
func _test_work_period_during_day() -> void:
var manager := _create_manager(200)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 100
var time_of_day: float = manager.clock.time_of_day()
_check(
time_of_day > 0.28 and time_of_day < 0.85,
"100 ticks should land in the work period"
)
var npc: SimNPC = manager.npcs[0]
npc.hunger = 20.0
npc.energy = 80.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
_check(selection != null, "Daytime should produce an action selection")
_check(
selection.action_id != SimulationIds.ACTION_SLEEP,
"A well-rested NPC should not choose SLEEP during work period"
)
manager.free()
func _test_meal_period_hunger() -> void:
var manager := _create_manager(300)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 50
var time_of_day: float = manager.clock.time_of_day()
_check(
time_of_day >= 0.25 and time_of_day <= 0.35,
"50 ticks should land in the breakfast meal period"
)
var npc: SimNPC = manager.npcs[0]
npc.hunger = 65.0
npc.energy = 80.0
manager.village.food = 5.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
_check(selection != null, "Meal period should produce an action selection")
_check(
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
"Hungry NPC during meal period with pantry food should WITHDRAW_FOOD"
)
manager.free()
func _test_sleep_restores_energy() -> void:
var manager := _create_manager(400)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.hunger = 20.0
npc.energy = 30.0
npc.home_position = npc.position
npc.set_task(SimulationIds.ACTION_SLEEP, 4.0)
npc.start_working()
var energy_before := npc.energy
for tick in 4:
manager.simulate_tick()
_check(npc.task_complete, "Sleep should complete after its duration")
_check(
npc.energy > energy_before,
"Completing sleep should restore energy"
)
manager.free()
func _test_schedule_serialization() -> void:
var manager := _create_manager(500)
manager.clock.cycle_duration_seconds = 180.0
manager.clock.elapsed_ticks = 30
var npc: SimNPC = manager.npcs[0]
npc.home_position = Vector3(3.0, 0.0, 4.0)
npc.position = Vector3(1.0, 0.0, 2.0)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(1)
_check(
restored.restore_state_from_json(saved_json),
"Schedule state should survive serialization round-trip"
)
_check(
is_equal_approx(restored.clock.cycle_duration_seconds, 180.0),
"Cycle duration should round-trip through save"
)
var restored_npc: SimNPC = restored.npcs[0]
_check(
restored_npc.home_position.distance_to(Vector3(3.0, 0.0, 4.0)) < 0.01,
"NPC home position should round-trip through save"
)
manager.free()
restored.free()
func _test_sleep_targets_home() -> void:
var manager := _create_manager(600)
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 0
var npc: SimNPC = manager.npcs[0]
npc.energy = 40.0
npc.home_position = Vector3(5.0, 0.0, 5.0)
npc.position = Vector3(0.0, 0.0, 0.0)
npc.set_task(SimulationIds.ACTION_SLEEP)
_check(npc.task_state == SimNPC.TASK_STATE_TRAVELING, "Sleep should put NPC in traveling state")
var resolved: bool = manager.resolve_npc_target(npc.id, npc.position)
_check(resolved, "Sleep target should resolve without an active world adapter")
_check(
npc.travel_target_position.distance_to(Vector3(5.0, 0.0, 5.0)) < 0.01,
"Sleep should target the NPC home position"
)
manager.free()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
+1 -1
View File
@@ -34,7 +34,7 @@ func _test_registry_integrity() -> void:
SimulationDefinitions.get_action(definition.action_id) == definition, SimulationDefinitions.get_action(definition.action_id) == definition,
"Action lookup should return '%s'" % definition.action_id "Action lookup should return '%s'" % definition.action_id
) )
_check(action_ids.size() == 9, "Registry should contain all nine executable prototype actions") _check(action_ids.size() == 10, "Registry should contain all ten executable prototype actions")
var profession_ids := SimulationDefinitions.get_profession_ids() var profession_ids := SimulationDefinitions.get_profession_ids()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions") _check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
+84 -6
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=7 format=3] [gd_scene load_steps=12 format=3]
[sub_resource type="StandardMaterial3D" id="Material_keep"] [sub_resource type="StandardMaterial3D" id="Material_keep"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.45, 0.42, 0.38, 1)
@@ -6,15 +6,15 @@ roughness = 0.85
[sub_resource type="BoxMesh" id="Mesh_keep"] [sub_resource type="BoxMesh" id="Mesh_keep"]
material = SubResource("Material_keep") material = SubResource("Material_keep")
size = Vector3(6, 6, 8) size = Vector3(7, 6, 9)
[sub_resource type="StandardMaterial3D" id="Material_parapet"] [sub_resource type="StandardMaterial3D" id="Material_parapet"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.42, 0.39, 0.35, 1)
roughness = 0.85 roughness = 0.85
[sub_resource type="BoxMesh" id="Mesh_parapet"] [sub_resource type="BoxMesh" id="Mesh_parapet"]
material = SubResource("Material_parapet") material = SubResource("Material_parapet")
size = Vector3(5, 1.5, 7) size = Vector3(6.6, 1.5, 7.8)
[sub_resource type="StandardMaterial3D" id="Material_turret"] [sub_resource type="StandardMaterial3D" id="Material_turret"]
albedo_color = Color(0.45, 0.42, 0.38, 1) albedo_color = Color(0.45, 0.42, 0.38, 1)
@@ -26,6 +26,28 @@ top_radius = 0.6
bottom_radius = 0.7 bottom_radius = 0.7
height = 4.0 height = 4.0
[sub_resource type="BoxMesh" id="Mesh_crenellation"]
material = SubResource("Material_parapet")
size = Vector3(0.55, 0.9, 0.7)
[sub_resource type="StandardMaterial3D" id="Material_pole"]
albedo_color = Color(0.24, 0.13, 0.07, 1)
roughness = 0.82
[sub_resource type="CylinderMesh" id="Mesh_pole"]
material = SubResource("Material_pole")
top_radius = 0.08
bottom_radius = 0.1
height = 3.5
[sub_resource type="StandardMaterial3D" id="Material_banner"]
albedo_color = Color(0.68, 0.12, 0.1, 1)
roughness = 0.65
[sub_resource type="BoxMesh" id="Mesh_banner"]
material = SubResource("Material_banner")
size = Vector3(0.14, 2.0, 1.25)
[node name="FortressBlockout" type="Node3D"] [node name="FortressBlockout" type="Node3D"]
[node name="Keep" type="MeshInstance3D" parent="."] [node name="Keep" type="MeshInstance3D" parent="."]
@@ -36,6 +58,62 @@ mesh = SubResource("Mesh_keep")
position = Vector3(0, 6.75, 0) position = Vector3(0, 6.75, 0)
mesh = SubResource("Mesh_parapet") mesh = SubResource("Mesh_parapet")
[node name="Turret" type="MeshInstance3D" parent="."] [node name="Crenellation_01" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 2.0, 3.5) position = Vector3(-2.8, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_02" type="MeshInstance3D" parent="."]
position = Vector3(-1.4, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_03" type="MeshInstance3D" parent="."]
position = Vector3(0, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_04" type="MeshInstance3D" parent="."]
position = Vector3(1.4, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_05" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 7.7, -3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_06" type="MeshInstance3D" parent="."]
position = Vector3(-2.8, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_07" type="MeshInstance3D" parent="."]
position = Vector3(-1.4, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_08" type="MeshInstance3D" parent="."]
position = Vector3(0, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_09" type="MeshInstance3D" parent="."]
position = Vector3(1.4, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Crenellation_10" type="MeshInstance3D" parent="."]
position = Vector3(2.8, 7.7, 3.1)
mesh = SubResource("Mesh_crenellation")
[node name="Turret_A" type="MeshInstance3D" parent="."]
position = Vector3(-3.2, 2.0, -4)
mesh = SubResource("Mesh_turret") mesh = SubResource("Mesh_turret")
[node name="Turret_B" type="MeshInstance3D" parent="."]
position = Vector3(3.2, 2.0, -4)
mesh = SubResource("Mesh_turret")
[node name="Turret_C" type="MeshInstance3D" parent="."]
position = Vector3(3.2, 2.0, 4)
mesh = SubResource("Mesh_turret")
[node name="RidgeBannerPole" type="MeshInstance3D" parent="."]
position = Vector3(0, 8.4, 3.8)
mesh = SubResource("Mesh_pole")
[node name="RidgeBanner" type="MeshInstance3D" parent="."]
position = Vector3(0, 9.2, 4.45)
mesh = SubResource("Mesh_banner")
+60 -8
View File
@@ -186,14 +186,6 @@ position = Vector3(-25, 7, 9)
[node name="Keep" parent="FortressBlockout" instance=ExtResource("5_fortress")] [node name="Keep" parent="FortressBlockout" instance=ExtResource("5_fortress")]
[node name="RidgeBannerPole" type="MeshInstance3D" parent="FortressBlockout/Keep"]
position = Vector3(2.2, 8.6, 2.8)
mesh = SubResource("Mesh_site_pole")
[node name="RidgeBanner" type="MeshInstance3D" parent="FortressBlockout/Keep"]
position = Vector3(2.2, 8.95, 3.35)
mesh = SubResource("Mesh_landmark_banner")
[node name="WaterRoot" type="Node3D" parent="."] [node name="WaterRoot" type="Node3D" parent="."]
[node name="RiverSurface" parent="WaterRoot" instance=ExtResource("9_water")] [node name="RiverSurface" parent="WaterRoot" instance=ExtResource("9_water")]
@@ -414,6 +406,66 @@ safety_risk = 0.02
comfort_distance = 16.0 comfort_distance = 16.0
discovery_priority = 0.75 discovery_priority = 0.75
[node name="FarmCrop_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-11, 0, 3)
node_id = &"farm_crop_01"
initial_amount = 8.0
yield_per_action = 2.0
safety_risk = 0.02
comfort_distance = 16.0
discovery_priority = 0.85
[node name="BerryBush_River_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(28, 0, -2)
node_id = &"berry_bush_river_02"
initial_amount = 5.0
yield_per_action = 1.5
safety_risk = 0.22
comfort_distance = 28.0
discovery_priority = 0.2
[node name="Tree_Forest_Grove_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-8, 0, -22)
node_id = &"tree_forest_grove_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 14.0
yield_per_action = 3.0
safety_risk = 0.3
comfort_distance = 30.0
discovery_priority = -0.1
[node name="Tree_Forest_Grove_02" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(-3, 0, -24)
node_id = &"tree_forest_grove_02"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 11.0
yield_per_action = 2.5
safety_risk = 0.32
comfort_distance = 32.0
discovery_priority = -0.2
[node name="WoodPile_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(21, 0, 1)
node_id = &"wood_pile_mill_01"
action_id = &"gather_wood"
resource_id = &"wood"
initial_amount = 4.0
yield_per_action = 1.0
safety_risk = 0.03
comfort_distance = 14.0
discovery_priority = 0.8
[node name="BerryPatch_Mill_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
position = Vector3(14, 0, 3)
node_id = &"berry_patch_mill_01"
initial_amount = 5.0
yield_per_action = 1.5
safety_risk = 0.1
comfort_distance = 20.0
discovery_priority = 0.45
[node name="StorageSites" type="Node3D" parent="WorldObjects"] [node name="StorageSites" type="Node3D" parent="WorldObjects"]
[node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] [node name="VillagePantry" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
+2 -2
View File
@@ -7,10 +7,10 @@ shader = ExtResource("1_water_shader")
[sub_resource type="BoxMesh" id="Mesh_water_body"] [sub_resource type="BoxMesh" id="Mesh_water_body"]
material = SubResource("ShaderMaterial_water") material = SubResource("ShaderMaterial_water")
size = Vector3(5, 0.08, 56) size = Vector3(8, 0.08, 58)
[node name="WaterBody" type="Node3D"] [node name="WaterBody" type="Node3D"]
position = Vector3(25, 0.12, 0) position = Vector3(25, 0.08, 0)
[node name="Mesh" type="MeshInstance3D" parent="."] [node name="Mesh" type="MeshInstance3D" parent="."]
mesh = SubResource("Mesh_water_body") mesh = SubResource("Mesh_water_body")
+2 -2
View File
@@ -7,10 +7,10 @@ shader = ExtResource("1_waterfall_shader")
[sub_resource type="BoxMesh" id="Mesh_waterfall_body"] [sub_resource type="BoxMesh" id="Mesh_waterfall_body"]
material = SubResource("ShaderMaterial_waterfall") material = SubResource("ShaderMaterial_waterfall")
size = Vector3(5, 10, 0.5) size = Vector3(8, 12, 0.5)
[node name="WaterfallBody" type="Node3D"] [node name="WaterfallBody" type="Node3D"]
position = Vector3(25, 5, -24) position = Vector3(25, 6, -24)
[node name="Mesh" type="MeshInstance3D" parent="."] [node name="Mesh" type="MeshInstance3D" parent="."]
mesh = SubResource("Mesh_waterfall_body") mesh = SubResource("Mesh_waterfall_body")
+13 -13
View File
@@ -1,19 +1,19 @@
[gd_scene load_steps=4 format=3] [gd_scene load_steps=4 format=3]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"] [sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_mist"]
lifetime_randomness = 0.2 lifetime_randomness = 0.35
spread = 180.0 spread = 180.0
gravity = Vector3(0, -0.5, 0) gravity = Vector3(0, -0.35, 0)
initial_velocity_min = 0.3 initial_velocity_min = 0.25
initial_velocity_max = 0.8 initial_velocity_max = 1.2
scale_min = 0.1 scale_min = 0.08
scale_max = 1.5 scale_max = 2.0
color = Color(0.85, 0.95, 0.95, 0.4) color = Color(0.82, 0.94, 0.96, 0.35)
emission_shape = 1 emission_shape = 1
emission_box_extents = Vector3(2.5, 1.0, 0.5) emission_box_extents = Vector3(4.5, 1.5, 1.0)
[sub_resource type="StandardMaterial3D" id="Material_mist"] [sub_resource type="StandardMaterial3D" id="Material_mist"]
albedo_color = Color(0.85, 0.95, 0.95, 0.35) albedo_color = Color(0.82, 0.94, 0.96, 0.28)
transparency = 1 transparency = 1
shading_mode = 0 shading_mode = 0
billboard_mode = 1 billboard_mode = 1
@@ -21,14 +21,14 @@ cull_mode = 0
[sub_resource type="QuadMesh" id="Mesh_mist"] [sub_resource type="QuadMesh" id="Mesh_mist"]
material = SubResource("Material_mist") material = SubResource("Material_mist")
size = Vector2(1.5, 1.5) size = Vector2(1.8, 1.8)
[node name="WaterfallMist" type="GPUParticles3D"] [node name="WaterfallMist" type="GPUParticles3D"]
emitting = true emitting = true
amount = 48 amount = 72
lifetime = 2.5 lifetime = 3.0
one_shot = false one_shot = false
preprocess = 1.0 preprocess = 1.5
speed_scale = 1.0 speed_scale = 1.0
local_coords = true local_coords = true
draw_order = 0 draw_order = 0
+43 -9
View File
@@ -1,20 +1,54 @@
shader_type spatial; shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
uniform float wave_strength : hint_range(0.0, 1.0) = 0.15; uniform float wave_strength : hint_range(0.0, 0.5) = 0.08;
uniform float wave_speed : hint_range(0.0, 5.0) = 1.2; uniform float wave_speed : hint_range(0.0, 5.0) = 0.8;
uniform float flow_speed : hint_range(0.0, 3.0) = 0.6;
uniform vec3 deep_color : source_color = vec3(0.06, 0.24, 0.38);
uniform vec3 shallow_color : source_color = vec3(0.18, 0.44, 0.58);
uniform vec3 foam_color : source_color = vec3(0.82, 0.88, 0.86);
uniform float rim_power : hint_range(0.5, 5.0) = 2.5;
uniform float sparkle_strength : hint_range(0.0, 1.0) = 0.12;
global uniform vec3 sun_direction = vec3(0.4, 0.8, 0.4);
void vertex() { void vertex() {
vec3 pos = VERTEX; vec3 pos = VERTEX;
float wave = sin(pos.z * 0.4 + TIME * wave_speed) * wave_strength; float wave_a = sin(pos.x * 0.35 + pos.z * 0.25 + TIME * wave_speed) * wave_strength;
pos.y += wave; float wave_b = cos(pos.x * 0.45 - pos.z * 0.55 + TIME * wave_speed * 0.7) * wave_strength * 0.6;
float wave_c = sin(pos.z * 0.7 + TIME * wave_speed * 1.3) * wave_strength * 0.35;
pos.y += wave_a + wave_b + wave_c;
VERTEX = pos; VERTEX = pos;
} }
void fragment() { void fragment() {
ALBEDO = vec3(0.12, 0.39, 0.55); vec3 view_dir = VIEW;
METALLIC = 0.15; vec3 normal = NORMAL;
ROUGHNESS = 0.1; vec3 view_norm = normalize(view_dir);
EMISSION = vec3(0.05, 0.19, 0.24) * 0.45;
ALPHA = 0.72; float rim = 1.0 - abs(dot(normal, view_norm));
rim = pow(rim, rim_power);
rim = smoothstep(0.15, 0.7, rim);
vec2 uv = UV;
uv.x += TIME * flow_speed * 0.4;
uv.y += TIME * flow_speed * 0.3;
float flow_a = sin(uv.x * 8.0 + uv.y * 5.0) * 0.5 + 0.5;
float flow_b = cos(uv.y * 6.0 - uv.x * 4.0 + TIME * 0.25) * 0.5 + 0.5;
float flow = mix(flow_a, flow_b, 0.5);
vec3 water_color = mix(deep_color, shallow_color, flow * 0.6 + 0.2);
water_color = mix(water_color, foam_color, rim * 0.65);
vec3 half_vec = normalize(view_norm + normalize(sun_direction));
float specular = pow(max(dot(normal, half_vec), 0.0), 120.0);
specular *= smoothstep(0.4, 0.85, rim);
float sparkle = specular * sparkle_strength;
ALBEDO = water_color;
METALLIC = 0.08;
ROUGHNESS = 0.22;
EMISSION = water_color * 0.04 + foam_color * sparkle;
ALPHA = 0.76;
ALPHA_SCISSOR_THRESHOLD = 0.0;
} }
+39 -10
View File
@@ -1,7 +1,12 @@
shader_type spatial; shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
uniform float scroll_speed : hint_range(0.0, 5.0) = 2.0; uniform float scroll_speed : hint_range(0.0, 5.0) = 1.8;
uniform vec3 deep_color : source_color = vec3(0.1, 0.45, 0.55);
uniform vec3 mid_color : source_color = vec3(0.45, 0.78, 0.82);
uniform vec3 foam_color : source_color = vec3(0.9, 0.95, 0.95);
uniform float foam_contrast : hint_range(0.5, 4.0) = 2.2;
uniform float sparkle_strength : hint_range(0.0, 0.8) = 0.25;
void vertex() { void vertex() {
} }
@@ -9,13 +14,37 @@ void vertex() {
void fragment() { void fragment() {
vec2 uv = UV; vec2 uv = UV;
uv.y += TIME * scroll_speed; uv.y += TIME * scroll_speed;
float pattern = fract(uv.y * 4.0);
pattern = smoothstep(0.3, 0.7, pattern); float band_a = fract(uv.y * 5.0 + sin(uv.x * 2.0) * 0.15);
pattern = mix(0.4, 1.0, pattern); band_a = smoothstep(0.2, 0.55, band_a) * smoothstep(0.8, 0.45, band_a);
vec3 base_color = mix(vec3(0.6, 0.85, 0.9), vec3(1.0, 1.0, 1.0), pattern);
ALBEDO = base_color; float band_b = fract(uv.y * 3.5 + cos(uv.x * 2.5 + TIME * 0.3) * 0.12);
ALPHA = mix(0.3, 0.7, pattern); band_b = smoothstep(0.1, 0.5, band_b) * smoothstep(0.85, 0.4, band_b);
EMISSION = vec3(0.36, 0.52, 0.54) * 0.35 * pattern;
METALLIC = 0.0; float foam_a = fract(uv.y * 4.0);
ROUGHNESS = 0.2; foam_a = pow(smoothstep(0.0, 0.12, foam_a) * smoothstep(0.3, 0.15, foam_a), foam_contrast);
float foam_b = fract(uv.y * 6.0 + uv.x * 1.5);
foam_b = pow(smoothstep(0.5, 0.58, foam_b) * smoothstep(0.7, 0.6, foam_b), foam_contrast * 0.8);
float foam = foam_a * 0.7 + foam_b * 0.3;
vec3 base = mix(deep_color, mid_color, band_a * 0.5 + band_b * 0.3);
vec3 color = mix(base, foam_color, foam);
float sparkle = pow(foam_a, 3.0) * sparkle_strength;
float highlight = band_a * band_b * 0.35;
float emission_value = sparkle + highlight * 0.3;
ALBEDO = color;
ALPHA = mix(0.25, 0.78, foam * 0.7 + 0.15);
EMISSION = color * emission_value;
METALLIC = 0.05;
ROUGHNESS = 0.15;
vec3 normal = NORMAL;
vec3 view_dir = VIEW;
vec3 view_norm = normalize(view_dir);
float rim = 1.0 - abs(dot(normal, view_norm));
ALPHA = mix(ALPHA, ALPHA * 0.5, rim * 0.6);
} }