Gododin Gododin Documentation

Linked repository source

examples/cavern_lab/live/project/expedition.gd

This read-only view comes from the file in the Gododin repository. It retains its design, example or evidence context.

Download the original file · Return to the guide

extends Node2D
## The Cavern Lab playable loop (CL-1 slice + CL-2 integration + CL-3 durable
## authoring): a generated cave a Godot player can walk, markers and an exit
## placed by native classification of scene-authored candidates, Shape-mode
## mask editing refined by the native kernel, and the durable authoring layer —
## saved inputs, undoable authoring changes, deliberate Resource sharing, an
## explicitly applied settings Resource, and a responsive supervised bake that
## feeds its published asset straight back into this same playable loop.
## Godot owns the scene, the player, collision, presentation, and every
## gameplay decision; the native CavernGenerator owns seeded generation on its
## background worker, explicit grid acceptance, and the spatial batch
## classification.
##
## Ownership contracts implemented here:
## - The assigned CaveGenerationSettings Resource is the saved authoring truth.
##   Submitting takes an explicit per-run copy; playing, resetting, and
##   regenerating never write back to the shared .tres. The Resource's changed
##   notification refreshes the HUD deliberately; regeneration waits for the
##   explicit Apply, which re-reads the assigned Resource into a new request.
## - Two scene instances share the assigned Resource and observe each other's
##   edits; resource_local_to_scene gives a deliberate per-instance copy.
## - Signal handlers record scalar identity only and defer all scene mutation
##   out of the native dispatch; cell bytes are taken through published_cells
##   on the next frame, never during signal emission.
## - A new cave installs into a disabled pending TileMapLayer in bounded
##   batches while the old cave stays playable, then swaps at a safe physics
##   boundary. Whatever bytes the swap displays are then accepted explicitly:
##   accept_grid loads exactly those bytes as the classification grid and
##   returns the accepted-grid identity the HUD names. Generated, refined, and
##   baked caves each classify against their own displayed bytes.
## - Candidates are scene-authored Marker2D positions. classify_positions
##   decides placeable/blocked against the accepted bytes; the first placeable
##   candidate hosts the exit, the next ones host the collected markers.
##   Moving a candidate or refining the mask changes native placement
##   decisions and therefore actual scene content.
## - Authoring changes are undoable: painted mask strokes, candidate moves,
##   and grid installs each push a bounded snapshot the Undo action restores.
## - Save inputs writes the Shape-mode mask and the candidate marker positions
##   to a cave_inputs Resource; a reopened expedition restores them before its
##   first request, so authored work survives restart.
## - A failed or cancelled request leaves the last committed cave playable. A
##   cancelled or failed bake leaves the last valid baked asset in place: the
##   supervised child runs out of frame, the panel stays responsive, and a
##   published session bake loads through the same install path as the
##   committed designer asset.

const CELL := 16
const WALL_SOURCE := 0
const WALL_ATLAS := Vector2i(0, 0)
const FLOOR_ATLAS := Vector2i(1, 0)
const INSTALL_USEC_BUDGET := 1500
const INSTALL_MIN_CELLS := 256
const PLACEABLE_FLAG := 255
const MAX_MARKERS := 3
const CANDIDATE_RADIUS := 6.0
const INPUTS_PATH := "res://cave_inputs.tres"
const BAKE_REQUEST_PATH := "res://bake/cave_bake_session_request.tres"
const BAKE_OUTPUT_PATH := "res://bake/cave_bake_session.tres"
const UNDO_LIMIT := 32
const OVERLAY_SOURCE := 0
const OVERLAY_ATLAS := Vector2i.ZERO

const BakeConsume := preload("res://bake/bake_consume.gd")
const BakeRequest := preload("res://bake/cave_bake_request.gd")
const BakeShell := preload("res://bake/bake_shell.gd")
const CaveInputs := preload("res://cave_inputs.gd")
const PickupScene := preload("res://pickup.tscn")

enum InstallState { IDLE, FILLING, COMMITTING }

var _request := {}
var _pending_request_id := 0
var _completed_queue: Array[int] = []
var _published_seen := 0
var _queued_result := {}
var _install_state := InstallState.IDLE
var _install_cells := PackedByteArray()
var _install_width := 0
var _install_height := 0
var _install_cursor := 0
var _install_identity := 0
var _install_source := "generated"
var _cave_identity := 0
var _last_result_error := 0
var _terminal_signalled_id := 0
var _displayed_cells := PackedByteArray()
var _displayed_width := 0
var _displayed_height := 0
var _shape_mode := false
var _mask := PackedByteArray()
var _mask_width := 0
var _mask_height := 0
var _markers_total := 0
var _markers_collected := 0
var _expedition_complete := false
var _undo_stack: Array = []
var _bake_shell: RefCounted
var _bake_active := false
var _pending_bake_load := false

@onready var _generator: Node = $Generator
@onready var _player: CharacterBody2D = $Player
@onready var _terrain_active: TileMapLayer = $Terrain
@onready var _terrain_pending: TileMapLayer = $PendingTerrain
@onready var _mask_overlay: TileMapLayer = $MaskOverlay
@onready var _candidates: Node2D = $Candidates
@onready var _candidates_view: Node2D = $CandidatesView
@onready var _markers: Node2D = $Markers
@onready var _exit: Area2D = $Exit
@onready var _status_label: Label = $Hud/Panel/Rows/Status
@onready var _identity_label: Label = $Hud/Panel/Rows/Identity
@onready var _settings_label: Label = $Hud/Panel/Rows/SettingsLine
@onready var _shape_button: Button = $Hud/Panel/Rows/Buttons/ShapeToggle
@onready var _refine_button: Button = $Hud/Panel/Rows/Buttons/Refine
@onready var _baked_button: Button = $Hud/Panel/Rows/Buttons/LoadBaked
@onready var _apply_button: Button = $Hud/Panel/Rows/Buttons2/ApplySettings
@onready var _save_inputs_button: Button = $Hud/Panel/Rows/Buttons2/SaveInputs
@onready var _undo_button: Button = $Hud/Panel/Rows/Buttons2/Undo
@onready var _bake_button: Button = $Hud/Panel/Rows/Buttons3/Bake
@onready var _cancel_bake_button: Button = $Hud/Panel/Rows/Buttons3/CancelBake
@onready var _lab_button: Button = $Hud/Panel/Rows/Buttons3/Lab
@onready var _bake_status: Label = $Hud/Panel/Rows/BakeStatus

func _ready() -> void:
	if OS.get_environment("GODODIN_BAKE_CONSUME") == "1":
		# Exported-app self-check: consume the committed baked asset the way a
		# shipped game would — no build owner, no editor helpers — then quit.
		var failures := BakeConsume.run(self)
		get_tree().quit(0 if failures.is_empty() else 1)
		return
	if not ClassDB.class_exists("CavernGenerator"):
		push_error("CavernGenerator is not registered; run gododin build first.")
		return
	var settings: Resource = _generator.settings
	if settings == null:
		push_error("the generator has no assigned CaveGenerationSettings resource")
		return
	# Explicit per-run copy of the saved settings; the Resource itself is never
	# written back during play.
	_request = {
		"width": int(settings.width),
		"height": int(settings.height),
		"seed": int(settings.seed),
		"fill_percent": int(settings.fill_percent),
		"smoothing_steps": int(settings.smoothing_steps),
		"minimum_region_size": int(settings.minimum_region_size),
	}
	var tile_set := _build_tile_set()
	_terrain_active.tile_set = tile_set
	_terrain_pending.tile_set = tile_set
	_terrain_pending.enabled = false
	_mask_overlay.tile_set = _build_overlay_tile_set()
	_mask_overlay.modulate = Color(1.0, 0.45, 0.3, 0.55)
	_mask_overlay.clear()
	_generator.generation_completed.connect(_on_generation_completed)
	_generator.generation_failed.connect(_on_generation_failed)
	# Deliberate Resource-change wiring: an Inspector edit refreshes this view;
	# it never starts expensive work on its own.
	settings.changed.connect(_on_settings_changed)
	_settings_label.text = _settings_text()
	_shape_button.toggled.connect(_set_shape_mode)
	_refine_button.pressed.connect(_refine_mask)
	_baked_button.pressed.connect(_load_baked_grid)
	_apply_button.pressed.connect(_apply_settings)
	_save_inputs_button.pressed.connect(_save_inputs_pressed)
	_undo_button.pressed.connect(_undo_authoring)
	_bake_button.pressed.connect(_bake_displayed_grid)
	_cancel_bake_button.pressed.connect(_cancel_bake)
	_lab_button.pressed.connect(_open_lab)
	_exit.reached.connect(_on_exit_reached)
	if OS.get_environment("GODODIN_BAKE_PLAY") == "1":
		# Exported-app gameplay gate: play the committed baked asset through
		# this scene's own loop — no build owner, no editor helpers.
		_load_baked_grid()
		_run_bake_play()
		return
	var restored_scenario := ""
	if ResourceLoader.exists(INPUTS_PATH):
		# Reopen: restore the saved mask, candidate markers, and chosen
		# scenario before the first request so authored work survives restart.
		restored_scenario = _load_inputs(INPUTS_PATH)
	if restored_scenario == "baked":
		_status_label.text = "restart — reconstructing the saved baked scenario"
		_load_baked_grid()
	else:
		_submit("generating cave")

func _open_lab() -> void:
	# The Lab view stays reachable from the polished game: it owns the
	# byte-compare and classification surfaces, and benchmark access goes
	# through it.
	get_tree().change_scene_to_file("res://main.tscn")

func _submit(why: String) -> void:
	_pending_request_id = _generator.background_submit(
		_request.width, _request.height, _request.seed,
		_request.fill_percent, _request.smoothing_steps,
		_request.minimum_region_size)
	if _pending_request_id < 0:
		_status_label.text = "request rejected (error %d)" % _generator.get_last_error()
		return
	_status_label.text = "%s — seed %d, request #%d" % [why, _request.seed, _pending_request_id]
	_identity_label.text = _identity_text()

func _identity_text() -> String:
	return "seed %d · request #%d · cave %d · grid #%d %s" % [
		_request.seed, _pending_request_id, _cave_identity,
		int(_generator.get_accepted_grid_id()), _grid_source_label()]

func _grid_source_label() -> String:
	if _displayed_cells.is_empty():
		return "(none)"
	return _install_source

func _on_generation_completed(request_id: int, error: int, _compute_usec: int) -> void:
	# Dispatch-safety contract: record identity only; the frame loop performs
	# retrieval and every scene mutation.
	_completed_queue.append(request_id)

func _on_generation_failed(request_id: int, error: int, _compute_usec: int) -> void:
	_last_result_error = error
	_terminal_signalled_id = request_id
	_status_label.text = "generation failed (request #%d, error %d) — last cave kept" % [
		request_id, error]

func _process(_delta: float) -> void:
	var published := int(_generator.pump_completion())
	if published > _published_seen:
		_published_seen = published
		var error := int(_generator.published_error())
		# A silent record — deliberately cancelled — reports through here; a
		# record whose terminal signal already fired keeps that text.
		if error != 0 and _terminal_signalled_id != published and _completed_queue.is_empty():
			_last_result_error = error
			_status_label.text = "generation cancelled (request #%d) — last cave kept" % published
	while not _completed_queue.is_empty():
		var request_id := _completed_queue[0]
		_completed_queue.remove_at(0)
		if _install_state != InstallState.IDLE:
			# Bounded pending rule: at most the installing level and one queued
			# result. A newer completion replaces the queued one.
			_queued_result = {"request_id": request_id}
			continue
		_begin_install(request_id)
	if _install_state == InstallState.FILLING:
		_fill_pending_batch()
	if _bake_active and _bake_shell != null:
		# The supervised bake child runs out of frame: the panel polls without
		# ever blocking, so the frame loop — and the playable cave with it —
		# keeps running for the whole bake.
		var disposition: String = _bake_shell.poll()
		if not disposition.is_empty():
			_bake_active = false
			_on_bake_finished(disposition)
	if _pending_bake_load and _install_state == InstallState.IDLE:
		_pending_bake_load = false
		_install_grid_asset(BAKE_OUTPUT_PATH, "baked session")
	# The committed cave keeps running its own physics; the player needs no
	# per-frame attention from this controller during ordinary play.

func _begin_install(request_id: int) -> void:
	var cells: PackedByteArray = _generator.published_cells(request_id)
	var width := int(_request.width)
	var height := int(_request.height)
	if cells == null or cells.size() != width * height:
		_status_label.text = "published result %d did not match the request — last cave kept" % request_id
		return
	_begin_install_cells(cells, width, height, "generated")

func _begin_install_cells(cells: PackedByteArray, width: int, height: int, source: String) -> void:
	if _install_state != InstallState.IDLE:
		_status_label.text = "an install is still in progress — %s grid kept waiting" % source
		return
	if _shape_mode:
		# An install supersedes unpainted mask edits, exactly like leaving
		# Shape mode by hand: the overlay clears with the mode.
		_set_shape_mode(false)
	if not _displayed_cells.is_empty():
		# Every install is an undoable authoring change: the currently
		# displayed grid becomes the snapshot an Undo restores.
		_push_undo({
			"kind": "grid",
			"cells": _displayed_cells.duplicate(),
			"width": _displayed_width,
			"height": _displayed_height,
			"source": _install_source,
		})
	_install_cells = cells
	_install_width = width
	_install_height = height
	_install_cursor = 0
	_install_state = InstallState.FILLING
	_install_source = source
	_status_label.text = "installing cave — %s" % source

func _fill_pending_batch() -> void:
	var total := _install_width * _install_height
	var frame_start := Time.get_ticks_usec()
	var painted := 0
	while _install_cursor < total:
		var index := _install_cursor
		var x := index % _install_width
		@warning_ignore("integer_division")
		var y := index / _install_width
		var coords := Vector2i(x, y)
		if _install_cells[index] == 255:
			_terrain_pending.set_cell(coords, WALL_SOURCE, WALL_ATLAS)
		else:
			_terrain_pending.set_cell(coords, WALL_SOURCE, FLOOR_ATLAS)
		_install_cursor += 1
		painted += 1
		if painted >= INSTALL_MIN_CELLS and Time.get_ticks_usec() - frame_start >= INSTALL_USEC_BUDGET:
			break
	if _install_cursor >= total:
		_install_state = InstallState.COMMITTING
		_commit_install()

func _commit_install() -> void:
	var spawn := _find_spawn(_install_cells, _install_width, _install_height)
	var active := _terrain_active
	var pending := _terrain_pending
	# Safe physics boundary: suspend movement, switch terrain and collision,
	# wait for the physics server to observe the new static bodies, place the
	# player, then resume. Until the swap the old cave stays fully playable.
	await get_tree().physics_frame
	_player.set_physics_process(false)
	active.enabled = false
	pending.enabled = true
	_terrain_active = pending
	_terrain_pending = active
	_cave_identity += 1
	if spawn.x >= 0:
		_player.global_position = Vector2(
			(spawn.x + 0.5) * CELL, (spawn.y + 0.5) * CELL)
	_player.velocity = Vector2.ZERO
	# The swap displayed exactly these bytes: accept them as the explicit
	# classification grid, then let the native placement decide the scene
	# content against them.
	_displayed_cells = _install_cells
	_displayed_width = _install_width
	_displayed_height = _install_height
	if not _accept_displayed_grid(_displayed_cells, _displayed_width, _displayed_height):
		_status_label.text = "accepting the displayed grid failed (error %d)" % _generator.get_last_error()
		return
	_place_expedition()
	_install_state = InstallState.IDLE
	# The two physics frames below let physics settle before play resumes.
	# A terminal report (a failed or cancelled request) can publish inside
	# that window on an uncapped host, and its "last cave kept" text is the
	# truth the player needs — the play help must not stomp it.
	var status_before_settle := _status_label.text
	await get_tree().physics_frame
	await get_tree().physics_frame
	_player.set_physics_process(true)
	if _status_label.text == status_before_settle:
		_status_label.text = "play — R regenerate · C cancel · Ctrl+Z undo · Esc quit"
	_identity_label.text = _identity_text()
	if not _queued_result.is_empty():
		var next: Dictionary = _queued_result
		_queued_result = {}
		if _install_state == InstallState.IDLE:
			_begin_install(int(next.request_id))

# Accepts the exact bytes the scene displays as the native classification grid.
# The returned identity is the accepted-grid id the HUD names; every placement
# decision that follows classifies against these bytes.
func _accept_displayed_grid(cells: PackedByteArray, width: int, height: int) -> bool:
	var accepted := int(_generator.accept_grid(cells, width, height))
	if accepted < 1:
		return false
	return true

# Classifies the scene-authored candidates against the accepted bytes and
# builds the actual scene content from the native decision: the first placeable
# candidate hosts the exit, the next ones host markers up to MAX_MARKERS.
func _place_expedition() -> void:
	for old in _markers.get_children():
		old.queue_free()
	_markers_total = 0
	_markers_collected = 0
	_expedition_complete = false
	var positions := PackedVector3Array()
	var radii := PackedFloat32Array()
	for candidate in _candidates.get_children():
		var at: Vector2 = (candidate as Node2D).global_position
		positions.append(Vector3(at.x, 0.0, at.y))
		radii.append(CANDIDATE_RADIUS)
	var flags: PackedByteArray = _generator.classify_positions(
		positions, radii, Vector3.ZERO, float(CELL))
	var candidates := _candidates.get_children()
	if flags.size() != candidates.size():
		_status_label.text = "classification rejected the candidates (error %d)" % _generator.get_last_error()
		return
	_candidates_view.set_flags(flags)
	var exit_placed := false
	var marker_index := 0
	for index in candidates.size():
		if flags[index] != PLACEABLE_FLAG:
			continue
		var at: Vector2 = (candidates[index] as Node2D).global_position
		if not exit_placed:
			_exit.global_position = at
			_exit.visible = true
			exit_placed = true
		elif marker_index < MAX_MARKERS:
			var pickup := PickupScene.instantiate()
			_markers.add_child(pickup)
			pickup.global_position = at
			pickup.collected.connect(_on_marker_collected)
			_markers_total += 1
			marker_index += 1
	_exit.set_sealed(_markers_total > 0)
	if _markers_total > 0:
		_status_label.text = "markers %d/%d — collect them, then reach the exit" % [
			_markers_collected, _markers_total]
	else:
		_status_label.text = "no markers required — reach the exit"

func _on_marker_collected() -> void:
	_markers_collected += 1
	if _markers_collected >= _markers_total:
		_exit.set_sealed(false)
		_status_label.text = "exit open — reach it"
	else:
		_status_label.text = "markers %d/%d" % [_markers_collected, _markers_total]

func _on_exit_reached() -> void:
	if _expedition_complete:
		return
	_expedition_complete = true
	_status_label.text = "expedition complete — grid #%d %s" % [
		int(_generator.get_accepted_grid_id()), _install_source]

func _set_shape_mode(enabled: bool) -> void:
	_shape_mode = enabled
	_shape_button.set_pressed_no_signal(enabled)
	_shape_button.text = "Shape: on" if enabled else "Shape: off"
	if enabled:
		if _displayed_cells.is_empty():
			_status_label.text = "no displayed grid to shape yet"
			_shape_mode = false
			_shape_button.set_pressed_no_signal(false)
			_shape_button.text = "Shape: off"
			return
		# The mask starts as the displayed cave: painting edits what you see,
		# and Refine hands the edited mask to the native kernel.
		_mask = _displayed_cells.duplicate()
		_mask_width = _displayed_width
		_mask_height = _displayed_height
		_update_mask_overlay()
		_status_label.text = "shape — left-click paints wall, right-click paints floor, then Refine"
	elif not _mask.is_empty():
		_mask_overlay.clear()
		_status_label.text = "shape off — unpainted mask edits discarded"

# One painted cell, shared by the mouse handler and the headless campaign.
func _paint_cell(cell: Vector2i, value: int) -> bool:
	if not _shape_mode or _mask.is_empty():
		return false
	if cell.x < 0 or cell.y < 0 or cell.x >= _mask_width or cell.y >= _mask_height:
		return false
	_mask[cell.y * _mask_width + cell.x] = value
	_update_mask_overlay()
	return true

# The editing visual surface: every cell where the authored mask differs from
# the displayed cave shows as a translucent overlay, so the player sees the
# edit before refining it.
func _update_mask_overlay() -> void:
	_mask_overlay.clear()
	if not _shape_mode or _mask.is_empty() or _displayed_cells.is_empty():
		return
	if _mask.size() != _displayed_cells.size():
		return
	for index in _mask.size():
		if _mask[index] != _displayed_cells[index]:
			var x := index % _mask_width
			@warning_ignore("integer_division")
			var y := index / _mask_width
			_mask_overlay.set_cell(Vector2i(x, y), OVERLAY_SOURCE, OVERLAY_ATLAS)

func _refine_mask() -> void:
	if not _shape_mode or _mask.is_empty():
		_status_label.text = "nothing to refine — enter Shape mode and paint first"
		return
	var refined: PackedByteArray = _generator.process_mask(
		_mask, _mask_width, _mask_height,
		_request.smoothing_steps, _request.minimum_region_size)
	if refined == null or refined.is_empty():
		_status_label.text = "refinement rejected (error %d)" % _generator.get_last_error()
		return
	_set_shape_mode(false)
	_mask = PackedByteArray()
	_begin_install_cells(refined, _mask_width, _mask_height, "refined")

func _load_baked_grid() -> void:
	_install_grid_asset("res://bake/cave_bake_data.tres", "baked")

# The one playable consumer for baked output: whatever a bake published — or
# the committed designer asset — installs through the same bounded path as a
# generated or refined grid, and the expedition replays placement against it.
func _install_grid_asset(path: String, source: String) -> void:
	var asset: Resource = load(path)
	if asset == null:
		_status_label.text = "the %s asset does not load" % source
		return
	var cells: PackedByteArray = asset.grid
	var width := int(asset.width)
	var height := int(asset.height)
	if cells == null or cells.is_empty() or cells.size() != width * height:
		_status_label.text = "the %s asset carries no usable grid" % source
		return
	_begin_install_cells(cells, width, height, source)

# --- CL-3: settings — deliberate notification wiring and explicit Apply. ---

func _settings_text() -> String:
	var settings: Resource = _generator.settings
	if settings == null:
		return "settings (none assigned)"
	return "settings %d×%d seed %d fill %d smooth %d region %d" % [
		int(settings.width), int(settings.height), int(settings.seed),
		int(settings.fill_percent), int(settings.smoothing_steps),
		int(settings.minimum_region_size)]

func _on_settings_changed() -> void:
	# The notification refreshes the view only; expensive regeneration waits
	# for the explicit Apply below (coalescing rule: never in a notification).
	_settings_label.text = _settings_text()

func _apply_settings() -> void:
	var settings: Resource = _generator.settings
	if settings == null:
		return
	_request = {
		"width": int(settings.width),
		"height": int(settings.height),
		"seed": int(settings.seed),
		"fill_percent": int(settings.fill_percent),
		"smoothing_steps": int(settings.smoothing_steps),
		"minimum_region_size": int(settings.minimum_region_size),
	}
	_submit("applying settings")

# --- CL-3: durable inputs — saved masks and candidate markers. ---

func _save_inputs_pressed() -> void:
	if save_inputs():
		_status_label.text = "inputs saved — mask and candidate markers survive restart"

func save_inputs(path := INPUTS_PATH) -> bool:
	var inputs := CaveInputs.new()
	inputs.mask = _mask.duplicate()
	inputs.mask_width = _mask_width
	inputs.mask_height = _mask_height
	inputs.marker_positions = PackedVector2Array()
	for candidate in _candidates.get_children():
		inputs.marker_positions.append((candidate as Node2D).position)
	# The chosen scenario a restart reconstructs: whatever the displayed grid
	# is — a seeded generation or a baked asset — plus the session's mask and
	# marker authoring. Session bakes stay machine-local and save as "baked"
	# (the committed designer asset they came from).
	inputs.scenario = "baked" if _install_source.begins_with("baked") else "generated"
	var save_error := ResourceSaver.save(inputs, path)
	if save_error != OK:
		_status_label.text = "saving inputs failed (error %d)" % save_error
		return false
	return true

func _load_inputs(path: String) -> String:
	var inputs: Resource = load(path)
	if inputs == null:
		return ""
	var mask: PackedByteArray = inputs.mask
	var width := int(inputs.mask_width)
	var height := int(inputs.mask_height)
	if mask != null and not mask.is_empty() and mask.size() == width * height:
		var valid := true
		for cell in mask:
			if cell != 0 and cell != 255:
				valid = false
				break
		if valid:
			_mask = mask.duplicate()
			_mask_width = width
			_mask_height = height
	var positions: PackedVector2Array = inputs.marker_positions
	var candidates := _candidates.get_children()
	if positions != null and positions.size() == candidates.size():
		for index in candidates.size():
			(candidates[index] as Node2D).position = positions[index]
	return String(inputs.scenario)

# --- CL-3: undoable authoring changes. ---

func _push_undo(entry: Dictionary) -> void:
	_undo_stack.append(entry)
	if _undo_stack.size() > UNDO_LIMIT:
		_undo_stack.remove_at(0)

func _push_undo_mask() -> void:
	if _mask.is_empty():
		return
	_push_undo({
		"kind": "mask",
		"cells": _mask.duplicate(),
		"width": _mask_width,
		"height": _mask_height,
	})

func _set_candidate_position(index: int, position: Vector2) -> bool:
	var candidates := _candidates.get_children()
	if index < 0 or index >= candidates.size():
		return false
	_push_undo({
		"kind": "candidate",
		"index": index,
		"position": (candidates[index] as Node2D).position,
	})
	(candidates[index] as Node2D).position = position
	return true

func _undo_authoring() -> void:
	if _undo_stack.is_empty():
		_status_label.text = "nothing to undo"
		return
	var entry: Dictionary = _undo_stack.pop_back()
	match String(entry.kind):
		"mask":
			_mask = (entry.cells as PackedByteArray).duplicate()
			_mask_width = int(entry.width)
			_mask_height = int(entry.height)
			_update_mask_overlay()
			_status_label.text = "undo — the mask stroke is reverted"
		"candidate":
			var candidates := _candidates.get_children()
			var index := int(entry.index)
			if index >= 0 and index < candidates.size():
				(candidates[index] as Node2D).position = entry.position
				_place_expedition()
				_status_label.text = "undo — the candidate marker is back"
		"grid":
			_status_label.text = "undo — the previous cave returns"
			_begin_install_cells(
				entry.cells, int(entry.width), int(entry.height), String(entry.source))
		_:
			_status_label.text = "undo — unknown authoring change ignored"

# --- CL-3: the responsive supervised bake panel. ---

func _bake_displayed_grid() -> void:
	_bake_request_from_displayed(BAKE_REQUEST_PATH, BAKE_OUTPUT_PATH)

func _bake_request_from_displayed(request_path: String, output_path: String) -> void:
	if OS.has_feature("template"):
		_bake_status.text = "baking is a development-host workflow"
		return
	if _displayed_cells.is_empty():
		_bake_status.text = "no displayed grid to bake yet"
		return
	var request := BakeRequest.new()
	request.mask = Image.create_from_data(
		_displayed_width, _displayed_height, false, Image.FORMAT_L8, _displayed_cells)
	request.smoothing_steps = int(_request.smoothing_steps)
	request.minimum_region_size = int(_request.minimum_region_size)
	request.output_path = output_path
	_bake_request(request, request_path, output_path)

func _bake_request(request: Resource, request_path: String, output_path: String) -> void:
	if _bake_shell == null:
		_bake_shell = BakeShell.new()
	elif _bake_shell.outstanding():
		_bake_status.text = "a bake is already running — cancel it first"
		return
	if not _bake_shell.launch(request, request_path, output_path):
		_bake_status.text = "bake refused: %s" % _bake_shell.last_error
		return
	_bake_active = true
	_bake_status.text = "baking %d×%d — the cave stays playable" % [
		request.mask.get_width(), request.mask.get_height()]

func _cancel_bake() -> void:
	if _bake_shell == null or not _bake_shell.outstanding():
		_bake_status.text = "no bake is running"
		return
	_bake_shell.cancel()

func _on_bake_finished(disposition: String) -> void:
	if disposition == "published":
		_bake_status.text = "bake published — loading it to play"
		_pending_bake_load = true
	elif _bake_shell.cancelled:
		_bake_status.text = "bake cancelled — the last valid baked asset is kept"
	else:
		_bake_status.text = "bake failed (%s) — the last valid baked asset is kept" % _bake_shell.last_error

# --- CL-3: the exported-app gameplay gate. ---

func _run_bake_play() -> void:
	# Consume the committed baked asset exactly as a shipped game would, then
	# run the same placement/collection/exit loop the headless campaign proves
	# — this scene's own functions, no editor helpers, no build owner.
	var failures := BakeConsume.run(self)
	if not failures.is_empty():
		get_tree().quit(1)
		return
	var guard := 0
	while _install_state != InstallState.IDLE or _cave_identity < 1:
		guard += 1
		if guard > 3600:
			push_error("bake play: the baked grid never reached play")
			get_tree().quit(1)
			return
		await get_tree().process_frame
	var baked: Resource = load("res://bake/cave_bake_data.tres")
	if baked == null or _displayed_cells != baked.grid:
		push_error("bake play: the displayed bytes are not the baked asset")
		get_tree().quit(1)
		return
	guard = 0
	while _markers.get_child_count() > 0 and guard < 64:
		guard += 1
		var pickup: Node2D = _markers.get_child(0)
		_player.global_position = pickup.global_position
		_player.velocity = Vector2.ZERO
		for _frame in range(6):
			await get_tree().physics_frame
	if _markers_total == 0 or _markers_collected != _markers_total:
		push_error("bake play: the markers never registered collection")
		get_tree().quit(1)
		return
	if _exit.sealed:
		push_error("bake play: collecting every marker never opened the exit")
		get_tree().quit(1)
		return
	_player.global_position = _exit.global_position
	_player.velocity = Vector2.ZERO
	for _frame in range(6):
		await get_tree().physics_frame
	if not _expedition_complete:
		push_error("bake play: reaching the opened exit never completed the expedition")
		get_tree().quit(1)
		return
	print("GODODIN_CAVERN_BAKE_PLAY_PASS baked_markers=%d exit_reached=true consume_oracle_equal=true gameplay=expedition_loop" % _markers_total)
	get_tree().quit(0)

func _find_spawn(cells: PackedByteArray, width: int, height: int) -> Vector2i:
	@warning_ignore("integer_division")
	var center := Vector2i(width / 2, height / 2)
	@warning_ignore("integer_division")
	for radius in range(0, max(width, height) / 2):
		for dy in range(-radius, radius + 1):
			for dx in range(-radius, radius + 1):
				if max(abs(dx), abs(dy)) != radius:
					continue
				var candidate := center + Vector2i(dx, dy)
				if _is_open(cells, width, height, candidate):
					return candidate
	return Vector2i(-1, -1)

func _is_open(cells: PackedByteArray, width: int, height: int, at: Vector2i) -> bool:
	for dy in range(-1, 2):
		for dx in range(-1, 2):
			var x := at.x + dx
			var y := at.y + dy
			if x < 0 or y < 0 or x >= width or y >= height:
				return false
			if cells[y * width + x] != 0:
				return false
	return true

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("regenerate"):
		_submit("generating cave")
	elif event.is_action_pressed("cancel_generation"):
		if _generator.background_cancel() == 1:
			_status_label.text = "cancelling — seed %d" % _request.seed
	elif event.is_action_pressed("quit_expedition"):
		get_tree().quit()
	elif event.is_action_pressed("undo_authoring"):
		_undo_authoring()
	elif _shape_mode and event is InputEventMouseButton and event.pressed:
		var world := get_global_mouse_position()
		var cell := Vector2i(floori(world.x / CELL), floori(world.y / CELL))
		var value := 255 if event.button_index == MOUSE_BUTTON_LEFT else 0
		# One press is one undoable stroke.
		_push_undo_mask()
		if _paint_cell(cell, value):
			_status_label.text = "shape — cell (%d, %d) now %s · Refine to apply" % [
				cell.x, cell.y, "wall" if value == 255 else "floor"]

func _build_tile_set() -> TileSet:
	var image := Image.create_empty(2 * CELL, CELL, false, Image.FORMAT_RGBA8)
	for y in range(CELL):
		for x in range(2 * CELL):
			# Restrained cave palette with deterministic per-pixel dither so
			# rock and floor stay readable without any texture asset.
			var n := ((x * 7 + y * 13 + ((x * x + y * y) % 5)) % 4)
			if x < CELL:
				var shade := 0.15 + 0.015 * float(n)
				image.set_pixel(x, y, Color(shade + 0.02, shade + 0.03, shade + 0.06))
			else:
				var shade := 0.30 + 0.02 * float(n)
				image.set_pixel(x, y, Color(shade + 0.03, shade, shade - 0.04))
	var tile_set := TileSet.new()
	tile_set.tile_size = Vector2i(CELL, CELL)
	tile_set.add_physics_layer()
	tile_set.set_physics_layer_collision_layer(0, 1)
	var source := TileSetAtlasSource.new()
	source.texture = ImageTexture.create_from_image(image)
	source.texture_region_size = Vector2i(CELL, CELL)
	# The source joins the TileSet before any tile exists: a tile created on a
	# detached source never sees the TileSet's physics layers.
	tile_set.add_source(source, WALL_SOURCE)
	source.create_tile(WALL_ATLAS)
	source.create_tile(FLOOR_ATLAS)
	var wall := source.get_tile_data(WALL_ATLAS, 0)
	wall.add_collision_polygon(0)
	wall.set_collision_polygon_points(0, 0, PackedVector2Array([
		Vector2(-CELL / 2.0, -CELL / 2.0), Vector2(CELL / 2.0, -CELL / 2.0),
		Vector2(CELL / 2.0, CELL / 2.0), Vector2(-CELL / 2.0, CELL / 2.0),
	]))
	return tile_set

# The Shape-mode overlay tile: one translucent cell-sized square, no physics,
# tinted through the layer's modulate.
func _build_overlay_tile_set() -> TileSet:
	var image := Image.create_empty(CELL, CELL, false, Image.FORMAT_RGBA8)
	image.fill(Color(1, 1, 1, 1))
	var tile_set := TileSet.new()
	tile_set.tile_size = Vector2i(CELL, CELL)
	var source := TileSetAtlasSource.new()
	source.texture = ImageTexture.create_from_image(image)
	source.texture_region_size = Vector2i(CELL, CELL)
	tile_set.add_source(source, OVERLAY_SOURCE)
	source.create_tile(OVERLAY_ATLAS)
	return tile_set