Files
Game-JAM/scripts/game_controller.gd

505 lines
16 KiB
GDScript

class_name GameController
extends Node
## Игровой контроллер без UI: связывает Game, HourDirector, Shop и SaveManager
## в законченный цикл «меню → подготовка → час → итоги часа → итоги забега».
##
## Контроллер не знает о сценах и не создаёт UI. Он тикает HourDirector вручную
## (UI вызывает tick(delta) из своего _process) и отдаёт готовые снимки данных
## через prep_data()/hour_data(). Фазы контроллера:
## menu → prep → hour → hour_result → prep … → run_result
##
## Важно: Game.end_hour() вызывается только в next_after_hour(), чтобы UI успел
## показать отчёт часа. Если Game завершил забег сам (9-й час или SLA = 0),
## сигнал Game.run_ended переводит контроллер в run_result без дублирования.
signal phase_changed(phase: StringName)
signal prep_changed
signal hour_started(hour: int)
signal hour_finished(report: Dictionary)
signal run_finished(report: Dictionary)
signal shop_changed
signal toast(text: String)
signal show_connection(game)
signal show_minigame(game)
## Дополнительно: проброс изменения слотов (удобно для UI во время часа).
signal slot_changed
const PHASE_MENU: StringName = &"menu"
const PHASE_PREP: StringName = &"prep"
const PHASE_HOUR: StringName = &"hour"
const PHASE_HOUR_RESULT: StringName = &"hour_result"
const PHASE_RUN_RESULT: StringName = &"run_result"
const PHASE_RESULTS: StringName = &"results"
const HOURS_PER_RUN := 9
const FOLDER_CAPACITY := 16
const HOUR_DIRECTOR_SCRIPT := preload("res://scripts/core/hour_director.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
## menu / prep / hour / hour_result / run_result.
var phase: StringName = PHASE_MENU
## Отчёт последнего завершённого часа (из HourDirector.report()).
var current_report: Dictionary = {}
## Итог забега из _finish_run() (см. run_result()).
var last_run_result: Dictionary = {}
var paused: bool = false
var modal_open: bool = false
## Дочерний тикер часа; создаётся лениво в _bind().
var hour_director = null
var shop = null
var game = null
func _ready() -> void:
_bind()
# --- жизненный цикл --------------------------------------------------------
func new_run(seed: int = 0) -> void:
_bind()
if game == null:
return
# Лучший счёт переживает новый забег: забираем его из старого save до сброса.
var carried_best := _best_from_save()
game.start_new_run(int(seed))
if carried_best > 0:
game.stats["best_score"] = maxi(int(game.stats.get("best_score", 0)), carried_best)
current_report = {}
last_run_result = {}
paused = false
modal_open = false
_reset_director_state()
_enter_prep()
func continue_run() -> bool:
_bind()
if game == null or not has_save():
return false
if not bool(game.load_game()):
return false
game.phase = PHASE_PREP
current_report = {}
last_run_result = {}
paused = false
modal_open = false
_reset_director_state()
_enter_prep()
return true
func has_save() -> bool:
return SAVE_SCRIPT.has_save()
func _enter_prep() -> void:
_bind()
phase = PHASE_PREP
paused = false
modal_open = false
if game != null:
game.phase = PHASE_PREP
# Автосейв на входе в подготовку: прогресс не теряется между часами.
game.save_game()
phase_changed.emit(phase)
prep_changed.emit()
# --- час -------------------------------------------------------------------
func start_hour() -> bool:
_bind()
if game == null or hour_director == null:
return false
# Час начинается только из подготовки (menu допустим, если Game уже в prep).
if phase != PHASE_PREP and not (phase == PHASE_MENU and game.phase == PHASE_PREP):
return false
if not bool(game.start_hour()):
return false
hour_director.start_hour(int(game.hour))
hour_director.set_paused(false)
hour_director.set_modal_open(false)
phase = PHASE_HOUR
paused = false
modal_open = false
current_report = {}
phase_changed.emit(phase)
hour_started.emit(int(game.hour))
return true
## Ручной тик: UI зовёт его из _process, пока контроллер в фазе hour.
func tick(delta: float) -> void:
if phase != PHASE_HOUR or paused:
return
if hour_director == null:
_bind()
if hour_director == null:
return
hour_director.tick(delta)
func set_paused(value: bool) -> void:
paused = bool(value)
if hour_director != null:
hour_director.set_paused(paused)
func set_modal_open(value: bool) -> void:
modal_open = bool(value)
if hour_director != null:
hour_director.set_modal_open(modal_open)
## Вызывается UI после показа отчёта часа. Здесь (и только здесь) двигаем Game.
func next_after_hour() -> void:
_bind()
if game == null or phase != PHASE_HOUR_RESULT:
return
game.end_hour()
# 9-й час: Game сам завершил забег и emit-нул run_ended — обработчик уже всё сделал.
if phase == PHASE_RUN_RESULT:
return
# Страховка, если Game выставил results без сигнала.
if game.phase == PHASE_RESULTS:
_finish_run("victory")
return
_enter_prep()
# --- магазин и сохранения --------------------------------------------------
func buy(id: String) -> bool:
_bind()
if shop == null or game == null:
return false
var bought := bool(shop.buy(id))
if bought:
game.save_game()
shop_changed.emit()
return bought
func save() -> bool:
_bind()
if game == null:
return false
return bool(game.save_game())
func load() -> bool:
_bind()
if game == null:
return false
var loaded := bool(game.load_game())
if loaded:
shop_changed.emit()
return loaded
# --- снимки данных для UI --------------------------------------------------
## Данные экрана подготовки. files — словарь folder_id → массив файлов;
## state: stored / attached / locked / malicious / failed (как ждёт FileGrid).
func prep_data() -> Dictionary:
_bind()
var sections_data: Array = []
var files_map: Dictionary = {}
var pending: Array = []
var registry = game.registry if game != null else null
if registry != null:
for section in registry.sections.values():
var folders_data: Array = []
var free_slots := 0
var capacity_total := 0
for folder_id in section.folder_ids:
var folder = registry.folders.get(folder_id)
if folder == null:
continue
var folder_files: Array = []
for file in registry.get_folder_files(str(folder.id)):
folder_files.append(_file_entry(file))
files_map[str(folder.id)] = folder_files
capacity_total += FOLDER_CAPACITY
free_slots += maxi(0, FOLDER_CAPACITY - folder.count() - int(folder.reserved))
folders_data.append({
"id": str(folder.id),
"name": str(folder.name),
"count": folder.count(),
"capacity": FOLDER_CAPACITY,
})
sections_data.append({
"id": str(section.id),
"name": str(section.name),
"free": free_slots,
"capacity": capacity_total,
"folders": folders_data,
})
for file in registry.get_pending_files():
pending.append(_file_entry(file))
return {
"sections": sections_data,
"files": files_map,
"pending": pending,
"money": int(game.money) if game != null else 0,
"sla": float(game.sla) if game != null else 0.0,
"hour": int(game.hour) if game != null else 1,
"upgrades": game.upgrades.duplicate(true) if game != null else {},
}
## Данные идущего часа: активные сущности + статус-бар.
func hour_data() -> Dictionary:
_bind()
var packets: Array = []
var requests: Array = []
var slots_data: Array = []
var status := {
"tcp_used": 0,
"tcp_max": 0,
"channel": 0.0,
"channel_factor": 1.0,
"channel_percent": 0.0,
"sla": float(game.sla) if game != null else 0.0,
"buffer_used": 0,
"buffer_max": 0,
"money": int(game.money) if game != null else 0,
"hour": int(game.hour) if game != null else 1,
"time_left": 0.0,
}
if hour_director == null:
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
var packet_ids: Array = hour_director.active_packets.keys()
packet_ids.sort_custom(func(a, b) -> bool:
return int(hour_director.active_packets[a].order) < int(hour_director.active_packets[b].order))
for id in packet_ids:
var packet = hour_director.active_packets[id]
var entry: Dictionary = packet.to_dict()
entry["display_name"] = packet.display_name()
entry["time_ratio"] = packet.time_ratio()
packets.append(entry)
for id in hour_director.active_requests.keys():
var request = hour_director.active_requests[id]
var req_entry: Dictionary = request.to_dict()
req_entry["ready"] = request.is_ready()
req_entry["time_ratio"] = request.time_ratio()
req_entry["missing"] = request.missing_count()
requests.append(req_entry)
var used := 0
var max_slots := 0
if hour_director.slots != null:
used = int(hour_director.slots.used_count())
max_slots = int(hour_director.slots.max_slots())
for slot in hour_director.slots.slots:
slots_data.append(slot.to_dict())
var channel_load := float(hour_director.channel_load())
var capacity := float(ECONOMY.channel_capacity(game.upgrades if game != null else {}))
var percent := 0.0
if capacity > 0.0:
percent = channel_load / capacity * 100.0
status = {
"tcp_used": used,
"tcp_max": max_slots,
"channel": channel_load,
"channel_factor": float(hour_director.channel_factor()),
"channel_percent": percent,
"sla": float(game.sla) if game != null else 0.0,
"buffer_used": int(hour_director.buffer_used()),
"buffer_max": int(hour_director.buffer_limit()),
"money": int(game.money) if game != null else 0,
"hour": int(game.hour) if game != null else int(hour_director.hour),
"time_left": float(hour_director.time_left),
}
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
func shop_data() -> Array:
_bind()
var out: Array = []
if shop == null:
return out
for item in shop.items():
var id := str(item.get("id", ""))
var item_name := str(item.get("name_ru", ""))
if item_name.is_empty():
item_name = str(item.get("name", id))
out.append({
"id": id,
"name": item_name,
"price": int(shop.price(id)),
"level": int(shop.level(id)),
"max_level": int(shop.max_level(id)),
"can_buy": bool(shop.can_buy(id)),
})
return out
func run_result() -> Dictionary:
return last_run_result.duplicate(true)
# --- обработчики -----------------------------------------------------------
func _on_hour_finished(report: Dictionary) -> void:
# Если забег уже проигран (SLA = 0), отчёт часа не должен перетирать run_result.
if phase == PHASE_RUN_RESULT:
return
current_report = report.duplicate(true)
paused = false
modal_open = false
phase = PHASE_HOUR_RESULT
phase_changed.emit(phase)
hour_finished.emit(current_report)
func _on_game_run_ended(reason: String) -> void:
_finish_run(str(reason))
func _on_hour_toast(text: String) -> void:
toast.emit(text)
func _on_show_connection(game_node) -> void:
modal_open = true
show_connection.emit(game_node)
func _on_show_minigame(game_node) -> void:
modal_open = true
show_minigame.emit(game_node)
func _on_slot_changed() -> void:
slot_changed.emit()
func _finish_run(reason: String) -> void:
if phase == PHASE_RUN_RESULT:
return
_bind()
if game == null:
return
phase = PHASE_RUN_RESULT
paused = false
modal_open = false
game.phase = PHASE_RESULTS
# Мета-прогресс: награды за число отработанных часов (переживают новый забег).
var hours_done := int(game.stats.get("hours_done", 0)) if game.stats is Dictionary else 0
# Часть наград уже выдана в end_hour — собираем их вместе с добираемыми на финише,
# чтобы итоговый экран показал все открытия этого забега (без дублей, с сохранением порядка).
var unlocked: Array = []
if game.get("run_meta_awards") is Array:
unlocked = (game.run_meta_awards as Array).duplicate()
if game.has_method("award_meta_for_run"):
for key in game.award_meta_for_run(hours_done):
if not unlocked.has(key):
unlocked.append(key)
last_run_result = _make_run_result(str(reason))
last_run_result["unlocked"] = unlocked.duplicate()
last_run_result["meta_unlocks"] = (game.meta_unlocks as Dictionary).duplicate(true) \
if game.meta_unlocks is Dictionary else {}
# Сохраняем забег вместе с best_score (лежит в stats) и мета-открытиями.
game.save_game()
phase_changed.emit(phase)
run_finished.emit(last_run_result)
# --- служебное -------------------------------------------------------------
func _bind() -> void:
if game == null:
game = _autoload("Game")
if shop == null:
shop = SHOP_SCRIPT.new()
if hour_director == null:
hour_director = HOUR_DIRECTOR_SCRIPT.new()
hour_director.name = "HourDirector"
add_child(hour_director)
hour_director.hour_finished.connect(_on_hour_finished)
hour_director.toast.connect(_on_hour_toast)
hour_director.show_connection.connect(_on_show_connection)
hour_director.show_minigame.connect(_on_show_minigame)
hour_director.slot_changed.connect(_on_slot_changed)
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
game.run_ended.connect(_on_game_run_ended)
## Приводит HourDirector к пустому состоянию между забегами/загрузками.
func _reset_director_state() -> void:
if hour_director == null:
return
hour_director.start_hour(1)
hour_director.running = false
hour_director.paused = false
hour_director.set_modal_open(false)
func _make_run_result(reason: String) -> Dictionary:
var stats: Dictionary = game.stats if game.stats is Dictionary else {}
var hours := int(stats.get("hours_done", 0))
var money_earned := int(stats.get("money_earned", 0))
var sla := float(game.sla)
var score := int(ECONOMY.score(hours, money_earned, sla))
var best := maxi(int(stats.get("best_score", 0)), score)
stats["best_score"] = best
game.stats = stats
var won := sla > 0.0 and (reason == "victory" or hours >= HOURS_PER_RUN)
return {
"won": won,
"reason": reason,
"score": score,
"hours": hours,
"money": int(game.money),
"money_earned": money_earned,
"sla": sla,
"best": best,
}
func _file_entry(file) -> Dictionary:
return {
"id": str(file.id),
"name": str(file.name),
"ext": str(file.ext),
"size_kb": int(file.size_kb),
"type": str(file.type),
"state": _file_state(file),
}
## FileGrid рисует state: stored / attached / locked / malicious / failed.
func _file_state(file) -> String:
var location := String(file.location)
if location == "folder" or location == "registry":
return "stored"
return location
func _best_from_save() -> int:
var data: Dictionary = SAVE_SCRIPT.load()
if data.is_empty():
return 0
var stats = data.get("stats", null)
if not (stats is Dictionary):
return 0
return maxi(0, int((stats as Dictionary).get("best_score", 0)))
func _autoload(node_name: String):
var loop := Engine.get_main_loop()
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null(node_name)
return null