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
## 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:
## Submitting takes an explicit per-run copy; playing, resetting, and
## notification refreshes the HUD deliberately; regeneration waits for the
## explicit Apply, which re-reads the assigned Resource into a new request.
## 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.
## 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.
## 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
## - 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
# 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"
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 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)
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