perf: harden runtime for weaker hardware
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user