perf: harden runtime for weaker hardware

This commit is contained in:
Rijad Zuzo
2026-08-12 10:02:45 +02:00
parent cd29da95e0
commit 34428d836a
47 changed files with 2164 additions and 243 deletions
+7 -3
View File
@@ -11,10 +11,10 @@ func _init(interval: float = 1.0) -> void:
tick_interval = maxf(interval, 0.0001)
func advance(delta: float) -> int:
func advance(delta: float, max_ticks: int = 0) -> int:
accumulator += maxf(delta, 0.0)
var ticks_due := 0
while accumulator >= tick_interval:
while accumulator >= tick_interval and (max_ticks <= 0 or ticks_due < max_ticks):
accumulator -= tick_interval
elapsed_ticks += 1
ticks_due += 1
@@ -27,5 +27,9 @@ func reset() -> void:
func time_of_day() -> float:
var total_seconds := elapsed_ticks * tick_interval + accumulator
# A capped frame can leave several whole ticks in the accumulator. Those ticks
# are backlog, not simulated time yet; only retain the sub-tick fraction for
# smooth presentation until the manager consumes the remaining work.
var fractional_progress := fmod(accumulator, tick_interval)
var total_seconds := elapsed_ticks * tick_interval + fractional_progress
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
+156 -131
View File
@@ -40,6 +40,7 @@ var player_system := PlayerCitizenSystem.new()
var npcs: Array[SimNPC] = []
@export var tick_interval := 1.2
@export_range(1, 16, 1) var max_ticks_per_frame := 4
@export var simulation_seed: int = 1337
@export var cycle_duration_seconds := 240.0
@export var debug_logs := false
@@ -72,6 +73,7 @@ var latest_decisions: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
var last_player_resource_query_stats: Dictionary = {}
var _population_view := SimulationPopulationView.new()
var speed_index := 2
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
@@ -115,7 +117,7 @@ func _ready() -> void:
economy.configure(village, debug_logs)
animal_care.configure(economy, active_world_adapter)
player_system.configure(economy, _record_player_event_at)
conflict_system.configure(economy)
conflict_system.configure(economy, tick_interval)
economy.initialize_storage()
village.update_modifiers()
village.update_priorities()
@@ -131,7 +133,8 @@ func _ready() -> void:
func _process(delta: float) -> void:
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index])
advance_player_combat_time(delta)
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index], max_ticks_per_frame)
for tick in ticks_due:
simulate_tick()
@@ -171,6 +174,10 @@ func generate_npcs() -> void:
if home_count > 0:
for i in range(npcs.size()):
npcs[i].home_position = home_positions[i % home_count]
refresh_population_index()
func refresh_population_index() -> void:
_population_view.rebuild(npcs)
@@ -196,7 +203,7 @@ func simulate_tick() -> void:
advance_player_needs()
_advance_resource_regrowth()
var village_was_changed := false
_population_view.rebuild(npcs)
refresh_population_index()
for npc in npcs:
village_was_changed = _simulate_npc_tick(npc) or village_was_changed
if village_was_changed:
@@ -299,21 +306,31 @@ func _on_conflict_combatant_spawned(combatant_id: StringName) -> void:
func _on_conflict_combatant_died(combatant_id: StringName) -> void:
combatant_died.emit(combatant_id)
var combatant := conflict_system.get_combatant(combatant_id)
if combatant == null or combatant.get_npc_id() < 0:
return
for npc in npcs:
if npc.id != combatant.get_npc_id() or npc.is_dead:
continue
_handle_npc_death(npc, npc.current_task, npc.target_id)
var npc: SimNPC
if combatant != null and combatant.get_npc_id() >= 0:
npc = _find_npc_by_id(combatant.get_npc_id())
if npc != null:
_population_view.refresh_npc(npc)
combatant_died.emit(combatant_id)
if npc == null or not npc.is_dead:
return
var previous_task := npc.last_task if npc.last_task != &"" else SimulationIds.ACTION_IDLE
_handle_npc_death(npc, previous_task, npc.target_id)
func player_attack(combatant_id: StringName) -> float:
return conflict_system.player_attack(combatant_id)
func player_attack_ready() -> bool:
return conflict_system.is_player_attack_ready()
func advance_player_combat_time(delta: float) -> void:
conflict_system.advance_realtime(delta)
func spawn_wolf(position: Vector3) -> StringName:
return conflict_system.spawn_wolf(position)
@@ -450,85 +467,80 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
func release_npc_reservation(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
if npc.target_id != &"":
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
var npc := _find_npc_by_id(npc_id)
if npc == null:
return
if npc.target_id != &"":
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 = &""
func notify_npc_arrived(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
if npc.is_dead:
var npc := _find_npc_by_id(npc_id)
if npc == null or npc.is_dead:
return
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
var definition := SimulationDefinitions.get_action(npc.current_task)
if (
npc.target_id != &""
and definition != null
and definition.target_type == SimulationIds.TARGET_RESOURCE
):
var resource_state := get_resource_state(npc.target_id)
if (
resource_state == null
or not resource_state.can_extract()
or resource_state.get_reserved_by() != npc.id
):
if debug_logs:
NpcTickDebugLog.print_unavailable_resource(npc)
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:
NpcTickDebugLog.print_unavailable_animal(npc)
notify_npc_navigation_failed(npc.id)
return
if animal_care.requires_feed_pickup(npc):
if _continue_animal_feed_delivery(npc):
return
_redirect_npc_to_wander(npc)
return
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
var definition := SimulationDefinitions.get_action(npc.current_task)
if (
npc.target_id != &""
and definition != null
and definition.target_type == SimulationIds.TARGET_RESOURCE
):
var resource_state := get_resource_state(npc.target_id)
if (
resource_state == null
or not resource_state.can_extract()
or resource_state.get_reserved_by() != npc.id
):
if debug_logs:
NpcTickDebugLog.print_unavailable_resource(npc)
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:
NpcTickDebugLog.print_unavailable_animal(npc)
notify_npc_navigation_failed(npc.id)
return
if animal_care.requires_feed_pickup(npc):
if _continue_animal_feed_delivery(npc):
return
_redirect_npc_to_wander(npc)
return
npc.has_travel_target = false
npc.start_working(
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
)
npc.has_travel_target = false
npc.start_working(
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
)
var working_speakers: Array[SimNPC] = []
if npc.target_id != &"":
for other in npcs:
if other.id == npc.id or other.is_dead:
continue
if other.target_id != npc.target_id:
continue
if other.task_state in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
if other.task_state == SimNPC.TASK_STATE_WORKING:
working_speakers.append(other)
working_speakers.sort_custom(_sort_npcs_by_id)
for speaker in working_speakers:
if try_communicate_at_shared_activity(speaker.id, npc.id):
break
var working_speakers: Array[SimNPC] = []
if npc.target_id != &"":
for other in npcs:
if other.id == npc.id or other.is_dead:
continue
if other.target_id != npc.target_id:
continue
if (
other.task_state
in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
):
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
if other.task_state == SimNPC.TASK_STATE_WORKING:
working_speakers.append(other)
working_speakers.sort_custom(_sort_npcs_by_id)
for speaker in working_speakers:
if try_communicate_at_shared_activity(speaker.id, npc.id):
break
if debug_logs:
NpcTickDebugLog.print_started_work(npc)
return
if debug_logs:
NpcTickDebugLog.print_started_work(npc)
func _continue_animal_feed_delivery(npc: SimNPC) -> bool:
@@ -564,66 +576,57 @@ func _redirect_npc_to_wander(npc: SimNPC) -> void:
func notify_npc_navigation_failed(npc_id: int) -> void:
for npc in npcs:
if npc.id != npc_id:
continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return
var failed_task := npc.current_task
_redirect_npc_to_wander(npc)
if debug_logs:
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
var npc := _find_npc_by_id(npc_id)
if npc == null or npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return
var failed_task := npc.current_task
_redirect_npc_to_wander(npc)
if debug_logs:
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
for npc in npcs:
if npc.id != npc_id:
continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.travel_target_position = npc.home_position
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
if result.is_empty():
notify_npc_navigation_failed(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc.travel_target_position = result["position"]
var npc := _find_npc_by_id(npc_id)
if npc == null or npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
if npc.current_task == SimulationIds.ACTION_SLEEP:
npc.travel_target_position = npc.home_position
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
return false
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
if result.is_empty():
notify_npc_navigation_failed(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc.travel_target_position = result["position"]
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
func request_current_travel(npc_id: int) -> bool:
for npc in npcs:
if npc.id != npc_id:
continue
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
if npc.has_travel_target:
npc_travel_requested.emit(npc, npc.travel_target_position)
else:
npc_target_requested.emit(npc)
return true
return false
var npc := _find_npc_by_id(npc_id)
if npc == null or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
if npc.has_travel_target:
npc_travel_requested.emit(npc, npc.travel_target_position)
else:
npc_target_requested.emit(npc)
return true
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
for npc in npcs:
if npc.id == npc_id:
npc.position = active_position
return true
return false
var npc := _find_npc_by_id(npc_id)
if npc == null:
return false
npc.position = active_position
return true
func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = -1) -> int:
@@ -891,6 +894,12 @@ func _get_lasting_event_ids(npc_id: int) -> Array[int]:
func _find_npc_by_id(npc_id: int) -> SimNPC:
var npc := _population_view.get_any(npc_id)
if npc != null:
return npc
# Population growth can introduce an ID between scheduled rebuild points.
# Rebuild once on a miss while keeping ordinary runtime callbacks O(1).
refresh_population_index()
return _population_view.get_any(npc_id)
@@ -1083,7 +1092,21 @@ func release_resource(node_id: StringName, agent_id: int) -> void:
func find_resource_node_for_player(from_position: Vector3, max_distance: float) -> ResourceNode:
var best: ResourceNode
var best_distance := max_distance * max_distance
for node in ResourceNode.get_all():
var candidates: Array[ResourceNode]
var query_mode := &"registry"
if (
active_world_adapter != null
and active_world_adapter.has_method("get_resource_nodes_in_radius")
):
candidates = active_world_adapter.get_resource_nodes_in_radius(from_position, max_distance)
query_mode = &"spatial"
else:
candidates = ResourceNode.get_all()
last_player_resource_query_stats = {
"mode": query_mode,
"candidate_count": candidates.size(),
}
for node in candidates:
var resource_state := get_resource_state(node.node_id)
if (
resource_state == null
@@ -1341,7 +1364,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
npcs.clear()
for npc_record in record.npcs:
npcs.append(npc_record.restore(debug_logs))
_population_view.rebuild(npcs)
refresh_population_index()
relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
@@ -1362,6 +1385,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
player_system.player_state = record.player
conflict_system.restore_state_records(record.combatants, record.factions, tick_count)
conflict_system.register_npc_combatants(npcs)
conflict_system.configure(economy, tick_interval)
_sync_player_combatant()
village_changed.emit(village)
state_restored.emit()
return true
@@ -1,8 +1,8 @@
class_name SimulationScalingBenchmark
extends RefCounted
const SCHEMA_VERSION := 1
const WORKLOAD_ID := &"full_fidelity_headless_arrival"
const SCHEMA_VERSION := 2
const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2"
const RECENT_FACTS_PER_NPC := 3
const STARTING_CLOCK_TICK := 50
@@ -43,6 +43,13 @@ func prepare_manager(
npc.home_position = npc.position
manager.npcs.append(npc)
manager.wander_random_sources[npc_id] = _create_random_source(seed_value, npc_id, 1)
manager.refresh_population_index()
var no_combatants: Array[CombatantStateRecord] = []
var no_factions: Array[FactionStateRecord] = []
manager.conflict_system.restore_state_records(no_combatants, no_factions, manager.tick_count)
manager.conflict_system.register_npc_combatants(manager.npcs)
if not _get_combatant_coverage(manager)["valid"]:
return false
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
@@ -63,11 +70,15 @@ func measure_manager(
or manager.npcs.size() != population
or warmup_ticks < 0
or measured_ticks <= 0
or not _get_combatant_coverage(manager)["valid"]
):
return {}
var warmup_arrivals := 0
for _tick in warmup_ticks:
warmup_arrivals += _advance_headless_tick(manager)
var warmup_coverage := _get_combatant_coverage(manager)
if not warmup_coverage["valid"] or warmup_coverage["npc_combatant_count"] != population:
return {}
var start_json: String = manager.serialize_state()
var start_event_count: int = manager.economic_events.size()
@@ -89,8 +100,12 @@ func measure_manager(
var start_state_bytes := start_json.to_utf8_buffer().size()
var end_state_bytes := end_json.to_utf8_buffer().size()
var ticks_per_second := float(measured_ticks) * 1000000.0 / float(elapsed_usec)
var combatant_coverage := _get_combatant_coverage(manager)
if not combatant_coverage["valid"] or combatant_coverage["npc_combatant_count"] != population:
return {}
return {
"schema_version": SCHEMA_VERSION,
"simulation_state_schema_version": SimulationStateRecord.SCHEMA_VERSION,
"workload_id": String(WORKLOAD_ID),
"seed": manager.simulation_seed,
"population": population,
@@ -98,6 +113,8 @@ func measure_manager(
"warmup_ticks": warmup_ticks,
"measured_ticks": measured_ticks,
"npc_updates": population * measured_ticks,
"npc_combatant_count": combatant_coverage["npc_combatant_count"],
"npc_combatant_coverage_valid": combatant_coverage["valid"],
"warmup_arrivals": warmup_arrivals,
"arrivals_processed": measured_arrivals,
"elapsed_usec": elapsed_usec,
@@ -121,6 +138,36 @@ func measure_manager(
}
func _get_combatant_coverage(manager: Node) -> Dictionary:
var expected_npcs := {}
for npc in manager.npcs:
expected_npcs[npc.id] = npc
var covered_npc_ids := {}
var npc_combatant_count := 0
var valid: bool = expected_npcs.size() == manager.npcs.size()
for combatant in manager.conflict_system.get_all_combatants():
if combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC:
continue
npc_combatant_count += 1
var npc_id: int = combatant.get_npc_id()
var npc := expected_npcs.get(npc_id) as SimNPC
if (
npc == null
or covered_npc_ids.has(npc_id)
or combatant.get_combatant_id() != SimulationIds.npc_combatant_id(npc_id)
or combatant.get_display_name() != npc.npc_name
or not combatant.get_position().is_equal_approx(npc.position)
):
valid = false
covered_npc_ids[npc_id] = true
valid = (
valid
and npc_combatant_count == expected_npcs.size()
and covered_npc_ids.size() == expected_npcs.size()
)
return {"valid": valid, "npc_combatant_count": npc_combatant_count}
func _advance_headless_tick(manager: Node) -> int:
manager.simulate_tick()
return _complete_headless_arrivals(manager)
+93 -25
View File
@@ -23,6 +23,7 @@ const STRONG_STRENGTH := 5.0
const DEFENSE_DECISION_INTERVAL := 200
const TRIBE_FOOD_DECAY := 0.05
const WOLF_HUNT_RADIUS := 18.0
const DEFAULT_TICK_INTERVAL := 1.2
const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
const RAID_SPEED := 1.2
const RAID_SIZE := 4
@@ -32,15 +33,23 @@ var combatants: Dictionary = {}
var factions: Dictionary = {}
var economy: RefCounted
var npcs: Array[SimNPC] = []
var _npcs_by_id: Dictionary = {}
var next_combatant_id := 0
var next_wolf_id := 0
var current_tick := 0
var village_center := Vector3.ZERO
var last_war_resolved_tick := -1
var tick_interval := DEFAULT_TICK_INTERVAL
var player_attack_cooldown_remaining := 0.0
var _sorted_combatant_ids_cache: Array[StringName] = []
var _combatant_ids_dirty := true
func configure(economy_service: RefCounted) -> void:
func configure(
economy_service: RefCounted, simulation_tick_interval: float = DEFAULT_TICK_INTERVAL
) -> void:
economy = economy_service
tick_interval = maxf(simulation_tick_interval, 0.0001)
func initialize_factions() -> void:
@@ -69,6 +78,7 @@ func _ensure_player_combatant() -> void:
sword.item_id if sword != null else SimulationIds.ITEM_SWORD,
CombatantStateRecord.NO_NPC_ID
)
_combatant_ids_dirty = true
func set_player_combatant_position(position: Vector3) -> void:
@@ -87,13 +97,9 @@ func set_player_health(health: float, downed: bool) -> void:
player_combatant = get_combatant(PLAYER_COMBATANT_ID)
if player_combatant == null:
return
if not downed and player_combatant.is_alive():
var target := clampf(health, 0.0, player_combatant.get_max_health())
var current := player_combatant.get_health()
if target < current:
player_combatant.take_damage(current - target)
elif target > current:
player_combatant.data["health"] = target
player_combatant.set_health_and_alive(health, not downed)
if downed:
player_attack_cooldown_remaining = 0.0
func is_player_downed() -> bool:
@@ -101,8 +107,30 @@ func is_player_downed() -> bool:
return player_combatant != null and not player_combatant.is_alive()
func advance_realtime(delta: float) -> void:
if not is_finite(delta) or delta <= 0.0:
return
player_attack_cooldown_remaining = maxf(player_attack_cooldown_remaining - delta, 0.0)
func is_player_attack_ready() -> bool:
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
return (
player_combatant != null
and player_combatant.is_alive()
and player_attack_cooldown_remaining <= 0.0
)
func get_player_attack_cooldown_remaining() -> float:
return player_attack_cooldown_remaining
func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
npcs = npc_list
_npcs_by_id.clear()
for npc in npcs:
_npcs_by_id[npc.id] = npc
var existing_ids := {}
for combatant in combatants.values():
var candidate := combatant as CombatantStateRecord
@@ -133,6 +161,7 @@ func _create_npc_combatant(npc: SimNPC) -> CombatantStateRecord:
npc.id
)
combatants[combatant.get_combatant_id()] = combatant
_combatant_ids_dirty = true
return combatant
@@ -170,12 +199,26 @@ func _advance_raider_positions() -> void:
func player_attack(combatant_id: StringName) -> float:
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
var combatant := get_combatant(combatant_id)
if combatant == null or not combatant.is_alive():
if (
not is_player_attack_ready()
or combatant == null
or not combatant.is_alive()
or not combatant.is_hostile()
or combatant.get_faction_id() == player_combatant.get_faction_id()
or (
_horizontal_distance(player_combatant.get_position(), combatant.get_position())
> _reach(player_combatant)
)
):
return 0.0
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
var damage := sword.damage if sword != null else 24.0
return _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID)
var applied := _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID)
if applied > 0.0:
player_attack_cooldown_remaining = sword.attack_cooldown if sword != null else 0.6
return applied
func spawn_wolf(position: Vector3) -> StringName:
@@ -194,6 +237,7 @@ func spawn_wolf(position: Vector3) -> StringName:
true
)
combatants[wolf_id] = wolf
_combatant_ids_dirty = true
combatant_spawned.emit(wolf_id)
return wolf_id
@@ -225,6 +269,11 @@ func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -
func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
var npc_id := target.get_npc_id()
if npc_id >= 0:
var npc := _find_npc(npc_id)
if npc != null and not npc.is_dead:
npc.last_task = npc.current_task
npc.die_from_combat()
_narrative_event_requested(
SimulationIds.EVENT_COMBATANT_KILLED,
killer_id,
@@ -235,10 +284,6 @@ func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
0.0
)
combatant_died.emit(target.get_combatant_id())
if npc_id >= 0:
var npc := _find_npc(npc_id)
if npc != null and not npc.is_dead:
npc.die_from_combat()
func _advance_wolves() -> void:
@@ -253,6 +298,17 @@ func _advance_wolves() -> void:
continue
if not combatant.is_hostile():
combatant.set_hostile(true)
var reach := _reach(combatant)
var to_target := target.get_position() - combatant.get_position()
to_target.y = 0.0
if to_target.length() > reach:
var definition := SimulationEnemies.get_enemy(&"enemy_wolf")
var speed := definition.move_speed if definition != null else 3.2
var step_distance := minf(speed * tick_interval, to_target.length() - reach)
combatant.set_position(
combatant.get_position() + to_target.normalized() * maxf(step_distance, 0.0)
)
continue
_attack_target(combatant, target)
@@ -386,6 +442,7 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
true
)
combatants[raider_id] = raider
_combatant_ids_dirty = true
raider_ids.append(raider_id)
combatant_spawned.emit(raider_id)
raid_started.emit(raider_ids)
@@ -456,8 +513,13 @@ func _finish_war(next_plan: StringName, outcome: StringName) -> void:
tribe.set_war_plan(next_plan, current_tick)
tribe.set_stance(SimulationIds.STANCE_NEUTRAL)
if village != null and outcome == &"village_lost":
var stolen := minf(village.get_food() * 0.6, 15.0)
village.set_food(maxf(village.get_food() - stolen, 0.0))
var stolen := 0.0
if economy != null:
var pantry: StorageStateRecord = economy.get_pantry()
if pantry != null:
var requested := minf(pantry.get_amount(SimulationIds.RESOURCE_FOOD) * 0.6, 15.0)
stolen = economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, requested)
village.set_food(pantry.get_amount(SimulationIds.RESOURCE_FOOD))
if tribe != null:
tribe.set_food(tribe.get_food() + stolen)
elif tribe != null:
@@ -486,7 +548,7 @@ func _attack_target(attacker: CombatantStateRecord, target: CombatantStateRecord
var damage := _attack_damage(attacker)
var applied := _apply_damage(target, damage, _actor_id_for(attacker))
if applied > 0.0:
attacker.mark_attacked()
attacker.mark_attacked(tick_interval)
func _attack_damage(attacker: CombatantStateRecord) -> float:
@@ -540,6 +602,10 @@ func _reach(combatant: CombatantStateRecord) -> float:
return weapon.reach
static func _horizontal_distance(first: Vector3, second: Vector3) -> float:
return Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z))
func _actor_id_for(combatant: CombatantStateRecord) -> int:
if combatant.get_npc_id() >= 0:
return combatant.get_npc_id()
@@ -607,22 +673,22 @@ func get_living_hostiles() -> Array[CombatantStateRecord]:
return hostiles
func _sorted_combatant_ids() -> Array:
func _sorted_combatant_ids() -> Array[StringName]:
if not _combatant_ids_dirty:
return _sorted_combatant_ids_cache
var keys: Array[String] = []
for combatant_id in combatants.keys():
keys.append(String(combatant_id))
keys.sort()
var ids: Array = []
_sorted_combatant_ids_cache.clear()
for key in keys:
ids.append(StringName(key))
return ids
_sorted_combatant_ids_cache.append(StringName(key))
_combatant_ids_dirty = false
return _sorted_combatant_ids_cache
func _find_npc(npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
return _npcs_by_id.get(npc_id) as SimNPC
func _get_faction(faction_id: StringName) -> FactionStateRecord:
@@ -668,9 +734,11 @@ func restore_state_records(
restored_tick: int
) -> void:
current_tick = restored_tick
player_attack_cooldown_remaining = 0.0
combatants.clear()
for combatant in combatant_records:
combatants[combatant.get_combatant_id()] = combatant
_combatant_ids_dirty = true
factions.clear()
for faction in faction_records:
factions[faction.get_faction_id()] = faction
+5 -4
View File
@@ -174,16 +174,17 @@ func restore_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> vo
func consume_npc_food(npc: SimNPC) -> bool:
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
if npc == null:
return false
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) < 1.0:
npc.hunger = minf(npc.hunger + 5.0, 100.0)
npc.is_starving = npc.hunger >= 90.0
return false
if remove_exact_from_inventory(npc, SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
return false
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
npc.starvation_ticks = 0
npc.is_starving = npc.hunger >= 90.0
inventory_changed.emit(
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
economic_event_requested.emit(
SimulationIds.EVENT_ITEM_CONSUMED,
npc.id,
+16 -4
View File
@@ -195,6 +195,18 @@ func set_hostile(value: bool) -> void:
changed.emit(self)
func set_health_and_alive(value: float, alive: bool) -> void:
if not is_finite(value):
return
var next_health := clampf(value, 0.0, get_max_health()) if alive else 0.0
var next_alive := alive and next_health > 0.0
if is_equal_approx(next_health, get_health()) and bool(data["alive"]) == next_alive:
return
data["health"] = next_health
data["alive"] = next_alive
changed.emit(self)
func tick_cooldown() -> void:
if get_attack_cooldown() > 0:
data["attack_cooldown"] = get_attack_cooldown() - 1
@@ -211,14 +223,14 @@ func take_damage(amount: float) -> float:
return previous - get_health()
func mark_attacked() -> void:
data["attack_cooldown"] = _weapon_cooldown_ticks()
func mark_attacked(tick_interval: float = 1.2) -> void:
data["attack_cooldown"] = _weapon_cooldown_ticks(tick_interval)
func _weapon_cooldown_ticks() -> int:
func _weapon_cooldown_ticks(tick_interval: float) -> int:
var weapon := SimulationItems.get_weapon(get_weapon_id())
var seconds := weapon.attack_cooldown if weapon != null else 0.6
return maxi(int(ceil(seconds / 1.2)), 1)
return maxi(int(ceil(seconds / maxf(tick_interval, 0.0001))), 1)
func to_dictionary() -> Dictionary: