Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
class_name SimulationKernel
|
||||
extends Node
|
||||
|
||||
signal time_changed(day: int, hour: int, minute: int)
|
||||
signal day_started(day: int)
|
||||
signal settlement_requested(day: int)
|
||||
signal tick_started(absolute_minute: int)
|
||||
signal tick_phase_started(phase: TickPhase, absolute_minute: int)
|
||||
signal tick_completed(absolute_minute: int)
|
||||
signal simulation_overloaded(backlog_minutes: int)
|
||||
|
||||
enum TickPhase {
|
||||
APPLY_COMMANDS,
|
||||
TIME_BOUNDARIES,
|
||||
MANA,
|
||||
CONSTRUCTION,
|
||||
LOGISTICS,
|
||||
FACILITY_COMPLETION,
|
||||
VITALS_AND_ENVIRONMENT,
|
||||
INCIDENTS_AND_EVENTS,
|
||||
FACILITY_QUEUES,
|
||||
AI_DECISIONS,
|
||||
PUBLISH_SNAPSHOT,
|
||||
}
|
||||
|
||||
const MINUTES_PER_DAY := 24 * 60
|
||||
const START_ABSOLUTE_MINUTE := 6 * 60
|
||||
const REAL_SECONDS_PER_GAME_HOUR := 30.0
|
||||
const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0
|
||||
const MAX_TICKS_PER_FRAME := 8
|
||||
const MAX_BACKLOG_MINUTES := 120
|
||||
|
||||
var absolute_minute := START_ABSOLUTE_MINUTE
|
||||
var day := 1
|
||||
var minute_of_day := START_ABSOLUTE_MINUTE
|
||||
var speed := 1
|
||||
var paused := false
|
||||
var snapshot_version := 0
|
||||
var performance_budget := SimulationPerformanceBudget.new()
|
||||
|
||||
var _accumulator := 0.0
|
||||
var _skip_next_process := false
|
||||
var _phase_handlers: Dictionary = {}
|
||||
var _pause_request_serial := 0
|
||||
var _abort_current_tick_requested := false
|
||||
var _last_tick_advanced := false
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_current_time()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
process_real_delta(delta)
|
||||
|
||||
func process_real_delta(delta: float) -> int:
|
||||
if _skip_next_process:
|
||||
_skip_next_process = false
|
||||
return 0
|
||||
if paused or speed <= 0 or delta <= 0.0:
|
||||
return 0
|
||||
|
||||
var frame_started_at_us := Time.get_ticks_usec()
|
||||
_accumulator += delta * speed
|
||||
var due_ticks := int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE)
|
||||
var tick_budget := mini(due_ticks, MAX_TICKS_PER_FRAME)
|
||||
var ticks_run := 0
|
||||
for _index in range(tick_budget):
|
||||
if paused:
|
||||
break
|
||||
advance_one_minute()
|
||||
if not _last_tick_advanced:
|
||||
break
|
||||
_accumulator -= REAL_SECONDS_PER_GAME_MINUTE
|
||||
ticks_run += 1
|
||||
# A boundary handler may pause in the middle of a catch-up frame. Never
|
||||
# consume another queued minute after that decision.
|
||||
if paused:
|
||||
break
|
||||
if ticks_run > 0:
|
||||
performance_budget.record_simulation_frame(
|
||||
int(Time.get_ticks_usec() - frame_started_at_us),
|
||||
speed,
|
||||
ticks_run
|
||||
)
|
||||
|
||||
var backlog_minutes := get_backlog_minutes()
|
||||
if backlog_minutes > MAX_BACKLOG_MINUTES:
|
||||
paused = true
|
||||
simulation_overloaded.emit(backlog_minutes)
|
||||
return ticks_run
|
||||
|
||||
func reset(start_day := 1, start_hour := 6, start_minute := 0) -> void:
|
||||
var safe_day := maxi(start_day, 1)
|
||||
var safe_minute := posmod(start_hour * 60 + start_minute, MINUTES_PER_DAY)
|
||||
absolute_minute = (safe_day - 1) * MINUTES_PER_DAY + safe_minute
|
||||
_sync_calendar_fields()
|
||||
_accumulator = 0.0
|
||||
paused = false
|
||||
snapshot_version = 0
|
||||
performance_budget.clear()
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = false
|
||||
_emit_current_time()
|
||||
|
||||
func advance_minutes(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
# Tests and deterministic tooling historically use an already-paused clock as
|
||||
# an explicit manual stepping mode. Preserve that contract, while a pause
|
||||
# raised during an initially-running batch is a hard boundary stop.
|
||||
var manual_paused_stepping := paused
|
||||
var pause_serial_before := _pause_request_serial
|
||||
for _index in range(amount):
|
||||
if not manual_paused_stepping and paused:
|
||||
break
|
||||
advance_one_minute()
|
||||
if not _last_tick_advanced:
|
||||
break
|
||||
if _pause_request_serial != pause_serial_before \
|
||||
or (not manual_paused_stepping and paused):
|
||||
break
|
||||
|
||||
func advance_one_minute() -> void:
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = false
|
||||
tick_started.emit(absolute_minute)
|
||||
_run_phase(TickPhase.APPLY_COMMANDS)
|
||||
# A Runtime gate can reject the tick before the Clock changes. This is
|
||||
# distinct from an ordinary pause raised by a command that already committed
|
||||
# for the upcoming minute.
|
||||
if _abort_current_tick_requested:
|
||||
_abort_current_tick_requested = false
|
||||
return
|
||||
|
||||
var previous_absolute_minute := absolute_minute
|
||||
absolute_minute += 1
|
||||
_sync_calendar_fields()
|
||||
_handle_time_boundaries(previous_absolute_minute)
|
||||
_run_phase(TickPhase.TIME_BOUNDARIES)
|
||||
|
||||
for phase in [
|
||||
TickPhase.MANA,
|
||||
TickPhase.CONSTRUCTION,
|
||||
TickPhase.LOGISTICS,
|
||||
TickPhase.FACILITY_COMPLETION,
|
||||
TickPhase.VITALS_AND_ENVIRONMENT,
|
||||
TickPhase.INCIDENTS_AND_EVENTS,
|
||||
TickPhase.FACILITY_QUEUES,
|
||||
TickPhase.AI_DECISIONS,
|
||||
TickPhase.PUBLISH_SNAPSHOT,
|
||||
]:
|
||||
_run_phase(phase)
|
||||
|
||||
snapshot_version += 1
|
||||
tick_completed.emit(absolute_minute)
|
||||
_emit_current_time()
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = true
|
||||
|
||||
func request_current_tick_abort() -> void:
|
||||
_abort_current_tick_requested = true
|
||||
|
||||
func set_speed(value: int) -> bool:
|
||||
if value not in [1, 2, 4]:
|
||||
return false
|
||||
speed = value
|
||||
paused = false
|
||||
return true
|
||||
|
||||
func toggle_pause() -> bool:
|
||||
paused = not paused
|
||||
if paused:
|
||||
_pause_request_serial += 1
|
||||
return paused
|
||||
|
||||
func set_paused(value: bool) -> void:
|
||||
paused = value
|
||||
if value:
|
||||
_pause_request_serial += 1
|
||||
|
||||
func get_hour() -> int:
|
||||
return minute_of_day / 60
|
||||
|
||||
func get_minute() -> int:
|
||||
return minute_of_day % 60
|
||||
|
||||
func get_backlog_minutes() -> int:
|
||||
return int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE)
|
||||
|
||||
func get_performance_reports() -> Array[Dictionary]:
|
||||
return performance_budget.get_all_reports()
|
||||
|
||||
func register_tick_handler(phase: TickPhase, handler_id: StringName, callback: Callable) -> bool:
|
||||
if handler_id.is_empty() or not callback.is_valid():
|
||||
return false
|
||||
if not _phase_handlers.has(phase):
|
||||
_phase_handlers[phase] = {}
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
if handlers.has(handler_id):
|
||||
return false
|
||||
handlers[handler_id] = callback
|
||||
return true
|
||||
|
||||
func unregister_tick_handler(phase: TickPhase, handler_id: StringName) -> void:
|
||||
if not _phase_handlers.has(phase):
|
||||
return
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
handlers.erase(handler_id)
|
||||
|
||||
func clock_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"absolute_minute": absolute_minute,
|
||||
&"speed": speed,
|
||||
&"paused": paused,
|
||||
&"snapshot_version": snapshot_version,
|
||||
}
|
||||
|
||||
func restore_clock(snapshot: Dictionary) -> bool:
|
||||
if not snapshot.has(&"absolute_minute"):
|
||||
return false
|
||||
var restored_speed := int(snapshot.get(&"speed", 1))
|
||||
if restored_speed not in [1, 2, 4]:
|
||||
return false
|
||||
absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0)
|
||||
speed = restored_speed
|
||||
paused = bool(snapshot.get(&"paused", true))
|
||||
snapshot_version = maxi(int(snapshot.get(&"snapshot_version", 0)), 0)
|
||||
_accumulator = 0.0
|
||||
_skip_next_process = true
|
||||
_sync_calendar_fields()
|
||||
_emit_current_time()
|
||||
return true
|
||||
|
||||
func _run_phase(phase: TickPhase) -> void:
|
||||
tick_phase_started.emit(phase, absolute_minute)
|
||||
if not _phase_handlers.has(phase):
|
||||
return
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
var handler_ids: Array = handlers.keys()
|
||||
handler_ids.sort_custom(func(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
for handler_id: Variant in handler_ids:
|
||||
var callback: Callable = handlers[handler_id]
|
||||
if callback.is_valid():
|
||||
callback.call(absolute_minute)
|
||||
|
||||
func _sync_calendar_fields() -> void:
|
||||
day = absolute_minute / MINUTES_PER_DAY + 1
|
||||
minute_of_day = absolute_minute % MINUTES_PER_DAY
|
||||
|
||||
func _handle_time_boundaries(previous_absolute_minute: int) -> void:
|
||||
if minute_of_day == 0:
|
||||
var completed_day := previous_absolute_minute / MINUTES_PER_DAY + 1
|
||||
settlement_requested.emit(completed_day)
|
||||
day_started.emit(day)
|
||||
|
||||
func _emit_current_time() -> void:
|
||||
time_changed.emit(day, get_hour(), get_minute())
|
||||
Reference in New Issue
Block a user