Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
extends SceneTree
|
||||
|
||||
const DEFAULT_OUTPUT_PATH := "res://assets/mockups/the-tree-runtime-ui-v6-actors.png"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_capture")
|
||||
|
||||
|
||||
func _capture() -> void:
|
||||
var capture_size := Vector2i(
|
||||
maxi(int(OS.get_environment("THE_TREE_CAPTURE_WIDTH")), 1440),
|
||||
maxi(int(OS.get_environment("THE_TREE_CAPTURE_HEIGHT")), 810)
|
||||
)
|
||||
var output_path := OS.get_environment("THE_TREE_CAPTURE_PATH")
|
||||
if output_path.is_empty():
|
||||
output_path = DEFAULT_OUTPUT_PATH
|
||||
var capture_viewport := SubViewport.new()
|
||||
capture_viewport.size = capture_size
|
||||
capture_viewport.size_2d_override = Vector2i(1440, 810)
|
||||
capture_viewport.size_2d_override_stretch = true
|
||||
capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(capture_viewport)
|
||||
var clock := root.get_node("GameClock")
|
||||
var state := root.get_node("GameState")
|
||||
var runtime := root.get_node("SimulationRuntime")
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
runtime.initialize(clock, state, 424242)
|
||||
clock.set_paused(true)
|
||||
var main := load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
capture_viewport.add_child(main)
|
||||
main.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
for frame: int in 8:
|
||||
await process_frame
|
||||
main.call("_hire_staff_candidate", &"staff_akane")
|
||||
main.call("_select_build_definition", &"reception")
|
||||
main.call("_on_map_cell_clicked", Vector3i(-6, 0, WorldService.LAYER_FRONT))
|
||||
clock.advance_minutes(120)
|
||||
main.call("_clear_active_tool")
|
||||
var facilities: Array[Dictionary] = runtime.construction_service.get_facilities()
|
||||
if not facilities.is_empty():
|
||||
main.call("_on_facility_selected", StringName(facilities[0].get(&"instance_id", &"")))
|
||||
for frame: int in 4:
|
||||
await process_frame
|
||||
var image := capture_viewport.get_texture().get_image()
|
||||
if image == null:
|
||||
push_error("Could not read the capture viewport texture.")
|
||||
quit(ERR_CANT_ACQUIRE_RESOURCE)
|
||||
return
|
||||
var result := image.save_png(output_path)
|
||||
if result != OK:
|
||||
push_error("Could not save UI capture: %s" % error_string(result))
|
||||
quit(result)
|
||||
@@ -0,0 +1 @@
|
||||
uid://denib08mi7jxr
|
||||
@@ -0,0 +1,184 @@
|
||||
extends SceneTree
|
||||
|
||||
## godot --path . --script res://tests/ui/capture_pixel_tower.gd
|
||||
## THE_TREE_PIXEL_PLAY=1 keeps an interactive, disposable scenario open instead.
|
||||
## THE_TREE_CAPTURE_SMOKE=1 validates the real scenario with --headless.
|
||||
## Optional: THE_TREE_CAPTURE_PATH, WIDTH, HEIGHT, MINUTES, GAP_MINUTES, LAYER.
|
||||
## THE_TREE_CAPTURE_ZOOM=1 (0.5..4); FOCUS_ROOMS=1 centers the built room area.
|
||||
## THE_TREE_CAPTURE_ANIMATION=1 also saves 72 live frames at 12 fps.
|
||||
## PATH names the first PNG; the second PNG and a state JSON use its basename.
|
||||
|
||||
const Scenario := preload("res://scripts/ui/pixel_tower_demo.gd")
|
||||
const DEFAULT_PATH := "res://artifacts/pixel-tower/pixel-tower.png"
|
||||
|
||||
var _scenario: RefCounted
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_clock = root.get_node("GameClock")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
var state: Variant = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
var initialized: Dictionary = _runtime.initialize(_clock, state, 424242)
|
||||
_clock.set_paused(true)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
if not bool(initialized.get(&"valid", false)):
|
||||
push_error("Could not initialize pixel viewing scenario.")
|
||||
quit(1)
|
||||
return
|
||||
_scenario = Scenario.new()
|
||||
if not await _scenario.prepare(_runtime, _clock, self):
|
||||
quit(1)
|
||||
return
|
||||
if not await _scenario.advance(_env_int("THE_TREE_CAPTURE_MINUTES", 83, 0, 180)):
|
||||
quit(1)
|
||||
return
|
||||
if OS.get_environment("THE_TREE_CAPTURE_SMOKE") == "1":
|
||||
print("PIXEL_SCENARIO ", JSON.stringify(_scenario.describe()))
|
||||
quit(0)
|
||||
return
|
||||
var capture_size := Vector2i(_env_int("THE_TREE_CAPTURE_WIDTH", 1440, 1024, 7680),
|
||||
_env_int("THE_TREE_CAPTURE_HEIGHT", 810, 640, 4320))
|
||||
var play := OS.get_environment("THE_TREE_PIXEL_PLAY") == "1"
|
||||
var container: Node = root
|
||||
var capture_viewport: SubViewport
|
||||
if play:
|
||||
root.size = capture_size
|
||||
root.title = "The Tree · ドット絵の見学(保存なし)"
|
||||
else:
|
||||
capture_viewport = SubViewport.new()
|
||||
capture_viewport.size = capture_size
|
||||
capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
root.add_child(capture_viewport)
|
||||
container = capture_viewport
|
||||
var main := load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
if main == null or not main.has_method("_set_layer"):
|
||||
push_error("Main scene did not load; run godot --headless --editor --path . --import first.")
|
||||
quit(ERR_CANT_ACQUIRE_RESOURCE)
|
||||
return
|
||||
container.add_child(main)
|
||||
main.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
if main.get("world_preview") == null:
|
||||
push_error("Main scene failed to create the live world preview.")
|
||||
quit(ERR_CANT_ACQUIRE_RESOURCE)
|
||||
return
|
||||
# main enables its production save policy in _ready; immediately turn it off
|
||||
# for this disposable scenario and make the manual save/load controls inert.
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
for button: Node in main.find_children("*", "Button", true, false):
|
||||
var save_control := button as Button
|
||||
for connection: Dictionary in save_control.pressed.get_connections():
|
||||
var callback: Callable = connection[&"callable"]
|
||||
if callback.get_method() in [&"_save_manual", &"_load_manual"]:
|
||||
save_control.disabled = true
|
||||
save_control.tooltip_text = "この見学モードは保存しません。"
|
||||
var layer := _env_int("THE_TREE_CAPTURE_LAYER", 0, 0, 1)
|
||||
main.call("_set_layer", layer)
|
||||
main.call("_clear_active_tool")
|
||||
for frame: int in 8:
|
||||
await process_frame
|
||||
var preview: Variant = main.get("world_preview")
|
||||
preview.set_zoom(_env_float("THE_TREE_CAPTURE_ZOOM", 1.0, 0.5, 4.0))
|
||||
if OS.get_environment("THE_TREE_CAPTURE_FOCUS_ROOMS") == "1":
|
||||
preview.pan_offset += preview.size * 0.5 - preview.cell_to_canvas(Vector3i(-2, 1, layer))
|
||||
preview.call("_clamp_pan")
|
||||
preview.queue_redraw()
|
||||
if play:
|
||||
main.call("_set_status", "ドット絵の見学:実際の客・従業員・建設済み施設で運営中(保存なし)", true, true)
|
||||
_clock.set_paused(false)
|
||||
print("PIXEL_PLAY: disposable scenario ready; normal saves are disabled.")
|
||||
return
|
||||
var path := OS.get_environment("THE_TREE_CAPTURE_PATH")
|
||||
if path.is_empty():
|
||||
path = DEFAULT_PATH
|
||||
var absolute_path := ProjectSettings.globalize_path(path)
|
||||
var directory_result := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir())
|
||||
if directory_result != OK:
|
||||
push_error("Could not create capture directory.")
|
||||
quit(directory_result)
|
||||
return
|
||||
var records: Array[Dictionary] = []
|
||||
for index: int in 2:
|
||||
for frame: int in 8:
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var image := capture_viewport.get_texture().get_image()
|
||||
if image == null or image.is_empty():
|
||||
push_error("Capture needs a rendering display; use CAPTURE_SMOKE for headless validation.")
|
||||
quit(ERR_CANT_ACQUIRE_RESOURCE)
|
||||
return
|
||||
var output := absolute_path if index == 0 else absolute_path.get_basename() + "-later.png"
|
||||
var result := image.save_png(output)
|
||||
if result != OK:
|
||||
push_error("Could not write %s: %s" % [output, error_string(result)])
|
||||
quit(result)
|
||||
return
|
||||
var description: Dictionary = _scenario.describe()
|
||||
description["image"] = output
|
||||
description["tracked_actor_count"] = (preview.get("_actor_tracks") as Dictionary).size()
|
||||
description["viewport_size"] = [capture_size.x, capture_size.y]
|
||||
records.append(description)
|
||||
print("PIXEL_CAPTURE: %s at %s" % [output, _clock.formatted_time()])
|
||||
if index == 0 and not await _scenario.advance(_env_int("THE_TREE_CAPTURE_GAP_MINUTES", 18, 1, 180)):
|
||||
quit(1)
|
||||
return
|
||||
var animation: Dictionary = {}
|
||||
if OS.get_environment("THE_TREE_CAPTURE_ANIMATION") == "1":
|
||||
var frames_path := absolute_path.get_basename() + "-frames"
|
||||
var frames_directory_result := DirAccess.make_dir_recursive_absolute(frames_path)
|
||||
if frames_directory_result != OK:
|
||||
push_error("Could not create animation frames directory.")
|
||||
quit(frames_directory_result)
|
||||
return
|
||||
animation = {"directory": frames_path, "fps": 12, "frame_count": 72,
|
||||
"start": _scenario.describe()}
|
||||
_clock.set_speed(1)
|
||||
_clock.set_paused(false)
|
||||
main.call("_update_pause_and_speed")
|
||||
# Normal real-time processing owns the clock and actor movement here.
|
||||
# Never manually advance simulation ticks or animation time for a frame.
|
||||
for frame: int in 72:
|
||||
await create_timer(1.0 / 12.0).timeout
|
||||
await RenderingServer.frame_post_draw
|
||||
var frame_image := capture_viewport.get_texture().get_image()
|
||||
if frame_image == null or frame_image.is_empty():
|
||||
_clock.set_paused(true)
|
||||
push_error("Could not read a live animation frame.")
|
||||
quit(ERR_CANT_ACQUIRE_RESOURCE)
|
||||
return
|
||||
var frame_output := frames_path.path_join("%03d.png" % frame)
|
||||
var frame_result := frame_image.save_png(frame_output)
|
||||
if frame_result != OK:
|
||||
_clock.set_paused(true)
|
||||
push_error("Could not write %s: %s" % [frame_output, error_string(frame_result)])
|
||||
quit(frame_result)
|
||||
return
|
||||
_clock.set_paused(true)
|
||||
animation["end"] = _scenario.describe()
|
||||
print("PIXEL_ANIMATION: %s (72 frames at 12 fps)" % frames_path)
|
||||
var manifest := FileAccess.open(absolute_path.get_basename() + "-state.json", FileAccess.WRITE)
|
||||
if manifest == null:
|
||||
quit(FileAccess.get_open_error())
|
||||
return
|
||||
var manifest_data := {"scenario": "disposable production simulation", "captures": records}
|
||||
if not animation.is_empty():
|
||||
manifest_data["animation"] = animation
|
||||
manifest.store_string(JSON.stringify(manifest_data, "\t"))
|
||||
quit(0)
|
||||
|
||||
|
||||
func _env_int(name: String, fallback: int, minimum: int, maximum: int) -> int:
|
||||
var value := OS.get_environment(name)
|
||||
return clampi(int(value), minimum, maximum) if value.is_valid_int() else fallback
|
||||
|
||||
|
||||
func _env_float(name: String, fallback: float, minimum: float, maximum: float) -> float:
|
||||
var value := OS.get_environment(name)
|
||||
return clampf(float(value), minimum, maximum) if value.is_valid_float() else fallback
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxh2wipu8gjrv
|
||||
@@ -0,0 +1,482 @@
|
||||
extends SceneTree
|
||||
|
||||
const MANUAL_SAVE_PATH := "user://saves/manual_ui"
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _main: Control
|
||||
var _preview: WorldPreview
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, 424242)
|
||||
_clock.set_paused(true)
|
||||
root.size = Vector2i(1440, 810)
|
||||
_expect(
|
||||
ProjectSettings.get_setting("display/window/stretch/mode") == "canvas_items"
|
||||
and ProjectSettings.get_setting("display/window/stretch/aspect") == "expand"
|
||||
and bool(ProjectSettings.get_setting("display/window/dpi/allow_hidpi")),
|
||||
"project uses DPI-aware canvas scaling for high-resolution displays"
|
||||
)
|
||||
_main = load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
root.add_child(_main)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_preview = _main.find_child("WorldPreview", true, false) as WorldPreview
|
||||
|
||||
_expect(_preview != null, "dashboard creates the live world preview")
|
||||
var visual_theme := _main.get("visual_theme") as VisualThemeProfile
|
||||
_expect(
|
||||
visual_theme != null
|
||||
and visual_theme.world_background_for_layer(WorldPreview.LayerMode.FRONT) != null
|
||||
and visual_theme.world_background_for_layer(WorldPreview.LayerMode.BACK) != null,
|
||||
"replaceable visual theme supplies independent FRONT and BACK art"
|
||||
)
|
||||
_expect(
|
||||
_preview.visual_theme == visual_theme,
|
||||
"world preview consumes the scene theme instead of hard-coded asset paths"
|
||||
)
|
||||
_expect(
|
||||
visual_theme.facility_icons.size() == 16
|
||||
and visual_theme.navigation_icons.size() == 6
|
||||
and visual_theme.resource_icons.size() == 4
|
||||
and visual_theme.control_icons.size() == 6,
|
||||
"replaceable visual theme provides complete semantic icon assignments"
|
||||
)
|
||||
_expect(
|
||||
visual_theme.guest_actor_marker(&"mage_elf") != null
|
||||
and visual_theme.guest_actor_marker(&"legacy_guest") != null
|
||||
and visual_theme.staff_actor_marker(&"guard") != null
|
||||
and visual_theme.staff_actor_marker(&"legacy_role") != null,
|
||||
"visual theme resolves class, role, and fallback actor markers"
|
||||
)
|
||||
_expect(
|
||||
visual_theme.ui_skin_textures.size() == 16
|
||||
and visual_theme.ui_skin(&"panel_parchment") != null
|
||||
and visual_theme.ui_skin(&"button_pressed") != null
|
||||
and visual_theme.ui_skin(&"queue_badge") != null
|
||||
and visual_theme.ui_skin(&"construction_hatch") != null,
|
||||
"visual theme provides replaceable panel, button, badge, route, and construction skins"
|
||||
)
|
||||
for texture_path: String in [
|
||||
"res://assets/themes/japanese_fantasy/world-front-v1.png.import",
|
||||
"res://assets/themes/japanese_fantasy/world-back-v1.png.import",
|
||||
"res://assets/themes/japanese_fantasy/facility-icons-v2.png.import",
|
||||
"res://assets/themes/japanese_fantasy/system-icons-v2.png.import",
|
||||
"res://assets/themes/japanese_fantasy/actor-markers-v2.png.import",
|
||||
"res://assets/themes/japanese_fantasy/ui-skin-v1.png.import",
|
||||
]:
|
||||
var import_config := ConfigFile.new()
|
||||
_expect(
|
||||
import_config.load(texture_path) == OK
|
||||
and bool(import_config.get_value("params", "mipmaps/generate", false)),
|
||||
"high-resolution texture enables mipmaps: %s" % texture_path.get_file()
|
||||
)
|
||||
var operations: Dictionary = _runtime.get_operations_report()
|
||||
_expect(bool(operations.get(&"enabled", false)), "dashboard enables the production Operations loop")
|
||||
var summary := _main.get("dashboard_summary_label") as Label
|
||||
_expect(
|
||||
summary != null and "行動点" in summary.text and "未払給与" in summary.text,
|
||||
"dashboard exposes action points and payroll arrears"
|
||||
)
|
||||
var palette := _main.find_child("BuildPalette", true, false) as GridContainer
|
||||
_expect(palette.get_child_count() == 16, "all sixteen MVP facilities appear in the build palette")
|
||||
var iconized_build_buttons := 0
|
||||
for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values():
|
||||
if (build_button_value as Button).icon != null:
|
||||
iconized_build_buttons += 1
|
||||
_expect(iconized_build_buttons == 16, "all facility catalog buttons consume theme-provided icons")
|
||||
var palette_scroll := _main.find_child("BuildPaletteScroll", true, false) as ScrollContainer
|
||||
_expect(
|
||||
palette_scroll != null and palette_scroll.is_ancestor_of(palette),
|
||||
"construction catalog lives in the persistent bottom palette"
|
||||
)
|
||||
var navigation := _main.find_child("NavigationRail", true, false) as Control
|
||||
_expect(
|
||||
navigation != null and (_main.get("navigation_buttons") as Dictionary).size() == 6,
|
||||
"dashboard exposes the six Japanese management categories"
|
||||
)
|
||||
var iconized_navigation_buttons := 0
|
||||
for navigation_button_value: Variant in (_main.get("navigation_buttons") as Dictionary).values():
|
||||
if (navigation_button_value as Button).icon != null:
|
||||
iconized_navigation_buttons += 1
|
||||
_expect(iconized_navigation_buttons == 6, "all management categories consume theme-provided icons")
|
||||
var resource_icon_nodes := _main.get("resource_icon_nodes") as Dictionary
|
||||
_expect(
|
||||
resource_icon_nodes.size() == 4
|
||||
and (resource_icon_nodes.get(&"gold") as TextureRect).texture != null
|
||||
and (resource_icon_nodes.get(&"mana") as TextureRect).texture != null,
|
||||
"top HUD consumes replaceable resource icons"
|
||||
)
|
||||
var alert_icon := _main.find_child("AlertIcon", true, false) as TextureRect
|
||||
_expect(alert_icon != null and alert_icon.texture != null, "top HUD consumes the theme warning icon")
|
||||
var selected_icon := _main.find_child("SelectedEntityIcon", true, false) as TextureRect
|
||||
_expect(selected_icon != null, "facility inspector reserves a theme-driven identity icon")
|
||||
var guest_count := _main.get("guest_count_label") as Label
|
||||
var lodging_count := _main.get("lodging_count_label") as Label
|
||||
var alert_count := _main.get("alert_count_label") as Label
|
||||
_expect(
|
||||
guest_count != null and "来客" in guest_count.text
|
||||
and lodging_count != null and "宿泊" in lodging_count.text
|
||||
and alert_count != null and "警戒" in alert_count.text,
|
||||
"top HUD keeps visitor, lodging, and alert information visible"
|
||||
)
|
||||
_expect((_main.get("build_buttons") as Dictionary).has(&"guest_elevator"), "connector proxy definitions are exposed for vertical-layout prototyping")
|
||||
_expect((_main.get("build_buttons") as Dictionary).has(&"adult_private_room"), "the full MVP catalog is reachable from the scrolling palette")
|
||||
await _check_layout(Vector2i(1440, 810))
|
||||
await _check_layout(Vector2i(1280, 720))
|
||||
await _check_layout(Vector2i(1920, 1080))
|
||||
await _check_layout(Vector2i(2560, 1440))
|
||||
await _check_layout(Vector2i(3840, 2160))
|
||||
_test_staff_candidate_hiring_ui()
|
||||
_test_excavation_rank_gate_and_tool_exclusion()
|
||||
_test_coordinate_round_trip()
|
||||
_test_build_payload_and_hit_test()
|
||||
_test_excavation_preview_payload_and_start()
|
||||
_test_manual_save_and_load()
|
||||
_test_event_choice_ui()
|
||||
|
||||
if is_instance_valid(_main):
|
||||
_main.queue_free()
|
||||
SaveService.delete_save(MANUAL_SAVE_PATH)
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _check_layout(window_size: Vector2i) -> void:
|
||||
root.size = window_size
|
||||
await process_frame
|
||||
await process_frame
|
||||
var side := _main.find_child("SidePanel", true, false) as Control
|
||||
var map_panel := _main.find_child("MapPanel", true, false) as Control
|
||||
var bottom := _main.find_child("BottomBar", true, false) as Control
|
||||
var navigation := _main.find_child("NavigationRail", true, false) as Control
|
||||
var excavation_button := _main.find_child("ExcavationButton", true, false) as Button
|
||||
_expect(side != null and side.size.x >= 300.0, "%dx%d keeps the operations sidebar readable" % [window_size.x, window_size.y])
|
||||
_expect(map_panel != null and map_panel.size.x >= 600.0 and map_panel.size.y >= 380.0, "%dx%d keeps a usable map area" % [window_size.x, window_size.y])
|
||||
_expect(
|
||||
navigation != null and navigation.position.x < map_panel.position.x and side.position.x > map_panel.position.x,
|
||||
"%dx%d keeps navigation left and facility inspection right of the living map" % [window_size.x, window_size.y]
|
||||
)
|
||||
_expect(bottom != null and bottom.position.y + bottom.size.y <= _main.size.y + 1.0, "%dx%d keeps all controls inside the viewport" % [window_size.x, window_size.y])
|
||||
_expect(
|
||||
excavation_button != null
|
||||
and excavation_button.position.x + excavation_button.size.x <= bottom.size.x + 1.0,
|
||||
"%dx%d keeps the excavation control inside the bottom bar" % [window_size.x, window_size.y]
|
||||
)
|
||||
|
||||
|
||||
func _test_staff_candidate_hiring_ui() -> void:
|
||||
var panel := _main.find_child("StaffCandidatePanel", true, false) as VBoxContainer
|
||||
var list := _main.find_child("StaffCandidateList", true, false) as VBoxContainer
|
||||
var toggle := _main.find_child("StaffCandidateToggleButton", true, false) as Button
|
||||
var buttons := _main.get("staff_candidate_buttons") as Dictionary
|
||||
_expect(panel != null and not panel.visible, "staff candidate screen starts collapsed")
|
||||
_expect(list != null and list.get_child_count() == 20, "all twenty validated Staff candidates have UI cards")
|
||||
_expect(toggle != null and "20" in toggle.text, "collapsed hiring control exposes the available candidate count")
|
||||
_expect(buttons.size() == 20 and buttons.has(&"staff_akane"), "every candidate has a stable hire control")
|
||||
var akane_detail := _main.find_child("CandidateDetail_staff_akane", true, false) as Label
|
||||
_expect(
|
||||
akane_detail != null
|
||||
and "アカネ" in akane_detail.text
|
||||
and "契約 G180" in akane_detail.text
|
||||
and "日給 G120" in akane_detail.text,
|
||||
"candidate card shows Japanese identity and both signing and payroll costs"
|
||||
)
|
||||
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_H))
|
||||
_expect(panel.visible and bool(_main.get("_staff_candidate_panel_open")), "H opens the Staff candidate screen")
|
||||
var cash_before: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH"))
|
||||
_main.call("_hire_staff_candidate", &"staff_akane")
|
||||
_expect(_runtime.get_hired_staff_candidate_ids().has(&"staff_akane"), "paused UI hire commits the selected canonical candidate immediately")
|
||||
_expect(_runtime.staff_service.has_staff(&"staff_akane"), "UI hire creates the Staff member through Runtime")
|
||||
_expect(
|
||||
_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 180,
|
||||
"UI hire charges the catalog signing cost exactly once"
|
||||
)
|
||||
var akane_button := buttons.get(&"staff_akane") as Button
|
||||
_expect(akane_button != null and akane_button.disabled and akane_button.text == "雇用済み", "hired candidate is visibly final and cannot be clicked again")
|
||||
var after_first_hire: int = int(_runtime.economy_service.get_balance(&"PLAYER_CASH"))
|
||||
_main.call("_hire_staff_candidate", &"staff_akane")
|
||||
_expect(
|
||||
_runtime.economy_service.get_balance(&"PLAYER_CASH") == after_first_hire
|
||||
and _runtime.get_hired_staff_candidate_ids().count(&"staff_akane") == 1,
|
||||
"repeated UI hire does not double-charge or duplicate the employee"
|
||||
)
|
||||
|
||||
_clock.set_paused(false)
|
||||
_main.call("_hire_staff_candidate", &"staff_bardo")
|
||||
var pending := _runtime.command_queue.create_snapshot().get("pending", []) as Array
|
||||
_expect(pending.size() == 1, "running-clock Staff hire is queued before mutation")
|
||||
_main.call("_hire_staff_candidate", &"staff_bardo")
|
||||
_expect(
|
||||
(_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1,
|
||||
"pending Staff hire suppresses repeated clicks"
|
||||
)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(
|
||||
_runtime.get_hired_staff_candidate_ids().has(&"staff_bardo")
|
||||
and (_main.get("_pending_hire_commands") as Dictionary).is_empty(),
|
||||
"queued Staff hire completes and clears its UI pending state"
|
||||
)
|
||||
_clock.set_paused(true)
|
||||
var candidate_summary := _main.find_child("StaffCandidateSummary", true, false) as Label
|
||||
_expect(candidate_summary != null and "雇用済み 2人" in candidate_summary.text, "candidate screen refreshes its hired count from Runtime state")
|
||||
_main.set("_hire_sequence", 1)
|
||||
_main.call("_synchronize_ui_command_state")
|
||||
_expect(
|
||||
int(_main.get("_hire_sequence")) >= 3,
|
||||
"saved Command metadata advances the UI hire-attempt sequence after reconstruction"
|
||||
)
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||||
_expect(not panel.visible and not bool(_main.get("_staff_candidate_panel_open")), "Escape closes the Staff candidate screen")
|
||||
|
||||
|
||||
func _test_excavation_rank_gate_and_tool_exclusion() -> void:
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_E))
|
||||
var status := _main.get("build_status_label") as Label
|
||||
var excavation_button := _main.get("excavation_button") as Button
|
||||
_expect(bool(_main.get("_excavation_mode")), "E enables the explicit excavation mode")
|
||||
_expect(_preview.excavation_mode, "dashboard and map share the excavation-mode state")
|
||||
_expect(excavation_button != null and excavation_button.button_pressed, "excavation toggle visibly reflects the keyboard shortcut")
|
||||
_expect(status != null and "ランク2" in status.text, "Rank 1 excavation gate is explained honestly in Japanese")
|
||||
|
||||
_main.call("_select_build_definition", &"reception")
|
||||
_expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "choosing a facility exits excavation mode")
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_E))
|
||||
_expect(_main.get("_selected_definition") == null, "entering excavation mode clears the facility build selection")
|
||||
var reception_button := (_main.get("build_buttons") as Dictionary).get(&"reception") as Button
|
||||
_expect(reception_button != null and not reception_button.button_pressed, "exclusive excavation mode clears the palette toggle")
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||||
_expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "Escape exits excavation mode")
|
||||
|
||||
|
||||
func _test_coordinate_round_trip() -> void:
|
||||
_preview.layer_mode = WorldPreview.LayerMode.FRONT
|
||||
for cell: Vector3i in [Vector3i(-12, 0, 0), Vector3i(0, 3, 0), Vector3i(12, 6, 0)]:
|
||||
var restored: Variant = _preview.canvas_to_cell(_preview.cell_to_canvas(cell))
|
||||
_expect(restored == cell, "FRONT cell-to-canvas round trip preserves %s" % cell)
|
||||
_preview.layer_mode = WorldPreview.LayerMode.BACK
|
||||
var back_cell := Vector3i(4, 0, 1)
|
||||
_expect(_preview.canvas_to_cell(_preview.cell_to_canvas(back_cell)) == back_cell, "BACK layer coordinate round trip preserves the layer")
|
||||
_expect(_preview.canvas_to_cell(Vector2(-10, -10)) == null, "points outside the map do not produce build cells")
|
||||
|
||||
|
||||
func _test_build_payload_and_hit_test() -> void:
|
||||
_main.call("_select_build_definition", &"reception")
|
||||
var origin := Vector3i(-6, 0, WorldService.LAYER_FRONT)
|
||||
var preview_report: Dictionary = _preview.get_build_preview_report(origin)
|
||||
_expect(bool(preview_report.get(&"can_build", false)), "starter reception shows a valid authoritative placement preview")
|
||||
var jobs_before: int = _runtime.construction_service.get_jobs().size()
|
||||
_main.call("_on_map_cell_clicked", origin)
|
||||
var jobs: Array = _runtime.construction_service.get_jobs()
|
||||
_expect(jobs.size() == jobs_before + 1, "map click submits one build_facility command")
|
||||
var job: Variant = jobs.back()
|
||||
_expect(job.definition_id == &"reception" and job.origin == origin and job.rotation == 0, "build command payload preserves definition, cell, and rotation")
|
||||
_main.call("_on_map_cell_clicked", origin)
|
||||
_expect(_runtime.construction_service.get_jobs().size() == jobs.size(), "invalid overlap preview does not submit a second command")
|
||||
_clock.advance_minutes(120)
|
||||
var facilities: Array[Dictionary] = _runtime.construction_service.get_facilities()
|
||||
_expect(facilities.size() == 1, "dashboard-created construction completes in simulation time")
|
||||
if facilities.is_empty():
|
||||
return
|
||||
var instance_id := StringName(facilities[0].get(&"instance_id", &""))
|
||||
var facility_point := _preview.cell_to_canvas(origin)
|
||||
_expect(_preview.facility_at_canvas(facility_point) == instance_id, "facility hit test resolves the completed world occupant")
|
||||
_main.call("_on_facility_selected", instance_id)
|
||||
var detail := _main.get("selected_detail_label") as Label
|
||||
_expect(detail != null and "待機列" in detail.text and "配置人員" in detail.text, "facility selection exposes queue, service, and staffing state")
|
||||
var selected_icon := _main.get("selected_entity_icon") as TextureRect
|
||||
_expect(selected_icon != null and selected_icon.texture != null, "facility selection exposes its theme icon in the inspector")
|
||||
|
||||
|
||||
func _test_excavation_preview_payload_and_start() -> void:
|
||||
_expect(_promote_rank_two(), "UI fixture reaches Rank 2 through public progression commands")
|
||||
_main.call("_set_layer", WorldPreview.LayerMode.FRONT)
|
||||
_main.call("_set_excavation_mode", true)
|
||||
var first_cell := Vector3i(0, -1, WorldPreview.LayerMode.FRONT)
|
||||
_preview.call("_set_hovered_cell", first_cell)
|
||||
var report := _preview.get_excavation_preview_report(first_cell)
|
||||
_expect(bool(report.get(&"ok", false)), "underground hover uses the authoritative excavation validator")
|
||||
var preview_cells: Variant = report.get(&"cells", [])
|
||||
_expect(
|
||||
preview_cells is Array
|
||||
and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.FRONT))
|
||||
and preview_cells.has(Vector3i(0, -1, WorldPreview.LayerMode.BACK)),
|
||||
"excavation preview identifies both FRONT and BACK target cells"
|
||||
)
|
||||
var status := _main.get("build_status_label") as Label
|
||||
_expect(
|
||||
status != null and "掘削可能" in status.text and "費用" in status.text and "所要" in status.text,
|
||||
"valid excavation preview shows Japanese cost and duration"
|
||||
)
|
||||
_main.call("_set_layer", WorldPreview.LayerMode.BACK)
|
||||
_expect(
|
||||
(_preview.get("_last_excavation_preview_report") as Dictionary).get(&"ok", false),
|
||||
"changing layer recomputes the active-layer excavation preview"
|
||||
)
|
||||
_main.call("_set_layer", WorldPreview.LayerMode.FRONT)
|
||||
_preview.call("_set_hovered_cell", first_cell)
|
||||
|
||||
var jobs_before: int = _runtime.excavation_service.get_jobs().size()
|
||||
_clock.set_paused(false)
|
||||
_main.call("_on_map_cell_clicked", first_cell)
|
||||
var pending_snapshot: Dictionary = _runtime.command_queue.create_snapshot()
|
||||
var pending: Array = pending_snapshot.get("pending", [])
|
||||
_expect(_runtime.excavation_service.get_jobs().size() == jobs_before, "running clock queues the excavation command before mutation")
|
||||
_expect(pending.size() == 1, "map click queues exactly one start_excavation command")
|
||||
if not pending.is_empty():
|
||||
var command: Dictionary = pending.back()
|
||||
var payload: Variant = command.get("payload", {})
|
||||
_expect(
|
||||
StringName(command.get("command_type", "")) == &"start_excavation"
|
||||
and payload is Dictionary
|
||||
and payload.get(&"cell", null) == first_cell,
|
||||
"excavation command payload preserves the hovered layer cell"
|
||||
)
|
||||
_expect(
|
||||
StringName(command.get("idempotency_key", "")) == &"ui.excavation.0.-1.00000001",
|
||||
"excavation uses a stable per-attempt idempotency key"
|
||||
)
|
||||
_expect(status != null and "受理しました" in status.text, "queued excavation reports command acceptance")
|
||||
_main.call("_on_map_cell_clicked", first_cell)
|
||||
_expect(
|
||||
(_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1,
|
||||
"a repeated click cannot queue a second excavation attempt while the first is pending"
|
||||
)
|
||||
_clock.advance_minutes(1)
|
||||
var first_jobs: Array[Dictionary] = _runtime.excavation_service.get_jobs()
|
||||
_expect(first_jobs.size() == jobs_before + 1, "queued excavation starts on the next Runtime command phase")
|
||||
_expect((_main.get("_pending_excavation_commands") as Dictionary).is_empty(), "queued completion clears the UI pending-command entry")
|
||||
_expect(status != null and "開始しました" in status.text, "queued command completion reports the actual start")
|
||||
if first_jobs.is_empty():
|
||||
_clock.set_paused(true)
|
||||
return
|
||||
var first_job: Dictionary = first_jobs.back()
|
||||
_expect(
|
||||
first_job.get(&"position", Vector2i.ZERO) == Vector2i(0, -1)
|
||||
and int(first_job.get(&"progress", 0)) > 0,
|
||||
"started excavation is visible and progresses through simulation time"
|
||||
)
|
||||
_clock.set_paused(true)
|
||||
_clock.advance_minutes(int(first_job.get(&"build_minutes", 0)))
|
||||
|
||||
var second_cell := Vector3i(0, -2, WorldPreview.LayerMode.FRONT)
|
||||
_preview.call("_set_hovered_cell", second_cell)
|
||||
var second_report := _preview.get_excavation_preview_report(second_cell)
|
||||
_expect(bool(second_report.get(&"ok", false)), "completed excavation unlocks the next connected underground cell")
|
||||
var jobs_before_immediate: int = _runtime.excavation_service.get_jobs().size()
|
||||
_main.call("_on_map_cell_clicked", second_cell)
|
||||
_expect(
|
||||
_runtime.excavation_service.get_jobs().size() == jobs_before_immediate + 1,
|
||||
"paused clock executes an accepted excavation command immediately"
|
||||
)
|
||||
var detail := _main.get("selected_detail_label") as Label
|
||||
_expect(
|
||||
detail != null and "進捗" in detail.text and "FRONT / 表層・BACK / 裏層" in detail.text,
|
||||
"started excavation exposes progress and both target layers in selection detail"
|
||||
)
|
||||
_main.call("_unhandled_key_input", _key_event(KEY_ESCAPE))
|
||||
_expect(not bool(_main.get("_excavation_mode")), "Escape still exits excavation mode after starting a job")
|
||||
|
||||
|
||||
func _promote_rank_two() -> bool:
|
||||
var reputation: Variant = _runtime.submit_command(
|
||||
&"update_progression_metrics",
|
||||
{&"metrics": {&"reputation": 25}},
|
||||
&"player", &"", 100, -1, &"ui.excavation.rank.reputation"
|
||||
)
|
||||
var capital: Variant = _runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"gold": maxi(10_000 - _runtime.economy_service.get_balance(&"PLAYER_CASH"), 0)}},
|
||||
&"player", &"", 100, -1, &"ui.excavation.rank.capital"
|
||||
)
|
||||
var promoted: Variant = _runtime.submit_command(
|
||||
&"set_world_tree_rank",
|
||||
{&"rank": 2},
|
||||
&"player", &"", 100, -1, &"ui.excavation.rank.promote"
|
||||
)
|
||||
return reputation.is_success() and capital.is_success() and promoted.is_success() \
|
||||
and _state.world_tree_rank == 2
|
||||
|
||||
|
||||
func _test_manual_save_and_load() -> void:
|
||||
SaveService.delete_save(MANUAL_SAVE_PATH)
|
||||
_main.call("_save_manual")
|
||||
var status := _main.get("build_status_label") as Label
|
||||
_expect(status != null and "保存しました" in status.text, "manual save button writes the runtime snapshot")
|
||||
var expected_gold: int = _state.get_resource(&"gold")
|
||||
_state.set_resource(&"gold", expected_gold + 7)
|
||||
_main.call("_load_manual")
|
||||
_expect(_state.get_resource(&"gold") == expected_gold, "manual load button restores the saved simulation")
|
||||
_expect(status != null and "読み込みました" in status.text, "manual load reports success in Japanese")
|
||||
|
||||
|
||||
func _test_event_choice_ui() -> void:
|
||||
var event_service: Variant = _runtime.event_service
|
||||
var queued: Variant = event_service.enqueue_candidate(
|
||||
&"relationship.threshold",
|
||||
[],
|
||||
_clock.absolute_minute,
|
||||
{&"deduplication_key": &"ui-event-choice-test"}
|
||||
)
|
||||
_expect(queued.is_success(), "UI fixture enqueues a foreground relationship Event")
|
||||
var started: Variant = event_service.start_next_event(_clock.absolute_minute, {})
|
||||
_expect(
|
||||
started.is_success() and bool(started.details.get(&"waiting_for_choice", false)),
|
||||
"UI fixture starts an Event waiting for a Choice"
|
||||
)
|
||||
_clock.set_paused(true)
|
||||
_main.call("_refresh_dashboard")
|
||||
var panel := _main.find_child("ForegroundEventPanel", true, false) as VBoxContainer
|
||||
var choices := _main.find_child("ForegroundEventChoices", true, false) as VBoxContainer
|
||||
_expect(panel != null and panel.visible, "foreground Event opens the decision panel")
|
||||
_expect(choices != null and choices.get_child_count() == 2, "all two canonical Event choices are visible")
|
||||
var pause_button := _main.get("pause_button") as Button
|
||||
_expect(
|
||||
pause_button != null and pause_button.disabled and "イベント" in pause_button.text,
|
||||
"unresolved foreground Event cannot be bypassed with the pause control"
|
||||
)
|
||||
if choices == null or choices.get_child_count() != 2:
|
||||
return
|
||||
var first_choice := choices.get_child(0) as Button
|
||||
_expect(
|
||||
first_choice != null and not first_choice.text.is_empty(),
|
||||
"Event choice uses a readable Japanese button"
|
||||
)
|
||||
first_choice.emit_signal(&"pressed")
|
||||
_expect(event_service.get_foreground_instance() == null, "choice button resolves the foreground Event exactly once")
|
||||
_expect(not _clock.paused, "resolved foreground Event releases the simulation pause")
|
||||
_expect(not panel.visible and choices.get_child_count() == 0, "resolved Event removes the stale choice controls")
|
||||
_expect(not pause_button.disabled, "resolved Event restores the normal pause control")
|
||||
_clock.set_paused(true)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
return
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _key_event(keycode: Key) -> InputEventKey:
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = keycode
|
||||
event.pressed = true
|
||||
return event
|
||||
@@ -0,0 +1 @@
|
||||
uid://c01w34xtnhfvm
|
||||
@@ -0,0 +1,284 @@
|
||||
extends SceneTree
|
||||
|
||||
## godot --headless --path . --script res://tests/ui/test_pixel_tower.gd
|
||||
## Camera coverage uses actual Runtime construction and World occupants: a
|
||||
## misplaced pointer must not select the next cell, another floor, or BACK.
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
var _preview: WorldPreview
|
||||
var _last_facility: StringName = &""
|
||||
var _last_construction: StringName = &""
|
||||
var _last_cell: Variant = null
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, 724242)
|
||||
_clock.set_paused(true)
|
||||
root.size = Vector2i(1280, 720)
|
||||
_preview = WorldPreview.new()
|
||||
_preview.size = Vector2(1100, 600)
|
||||
_preview.set_runtime(_runtime)
|
||||
var pixel_theme := load("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") as VisualThemeProfile
|
||||
_expect(pixel_theme != null and pixel_theme.pixel_dungeon_tiles != null
|
||||
and pixel_theme.pixel_town_tiles != null,
|
||||
"serialized theme loads both real Kenney atlases for actors and scenery")
|
||||
if pixel_theme != null:
|
||||
_preview.set_visual_theme(pixel_theme)
|
||||
if pixel_theme.pixel_dungeon_tiles != null:
|
||||
_expect(pixel_theme.pixel_dungeon_tiles.get_size() == Vector2(192, 176),
|
||||
"packed actor atlas matches the 12-column source regions")
|
||||
root.add_child(_preview)
|
||||
_preview.set_process(false)
|
||||
_preview.facility_selected.connect(_on_facility_selected)
|
||||
_preview.construction_selected.connect(_on_construction_selected)
|
||||
_preview.cell_clicked.connect(_on_cell_clicked)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
_test_zoom_anchor_and_reset()
|
||||
_test_construction_and_facility_clicks()
|
||||
_test_expanded_world_round_trips()
|
||||
_test_grid_preserves_hit_testing()
|
||||
_test_animation_pause_and_simulation_isolation()
|
||||
|
||||
_preview.queue_free()
|
||||
await process_frame
|
||||
if _failures == 0:
|
||||
print("PASS: %d pixel tower checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d pixel tower checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_zoom_anchor_and_reset() -> void:
|
||||
_preview.reset_view()
|
||||
var overview := _preview.get_map_rect()
|
||||
var anchor_cell := Vector3i(-4, 2, WorldPreview.LayerMode.FRONT)
|
||||
var anchor := _preview.cell_to_canvas(anchor_cell)
|
||||
_preview.set_zoom(2.0, anchor)
|
||||
_expect(_preview.canvas_to_cell(anchor) == anchor_cell,
|
||||
"zoom anchored at a room keeps that room under the pointer")
|
||||
_expect(_preview.cell_to_canvas(anchor_cell).distance_to(anchor) <= 2.0,
|
||||
"cursor-anchored zoom keeps the same location within the room")
|
||||
_expect(_preview.get_cell_rect(anchor_cell).size.y > _preview.get_cell_rect(anchor_cell).size.x,
|
||||
"tower floors retain the rectangular cell aspect used by hit testing")
|
||||
_preview.pan_offset += Vector2(137, -73)
|
||||
_preview.reset_view()
|
||||
var restored := _preview.get_map_rect()
|
||||
_expect(restored.position.is_equal_approx(overview.position) and restored.size.is_equal_approx(overview.size),
|
||||
"reset returns to the original full overview after both pan and zoom")
|
||||
_expect(_preview.canvas_to_cell(_preview.cell_to_canvas(anchor_cell)) == anchor_cell,
|
||||
"reset keeps authoritative world coordinates selectable")
|
||||
|
||||
|
||||
func _test_construction_and_facility_clicks() -> void:
|
||||
var origin := Vector3i(-6, 0, WorldPreview.LayerMode.FRONT)
|
||||
var result: Variant = _runtime.submit_command(
|
||||
&"build_facility", {&"definition_id": &"reception", &"origin": origin, &"rotation": 0},
|
||||
&"player", &"", 50, -1, &"pixel.camera.reception"
|
||||
)
|
||||
_expect(result != null and result.is_success(), "camera fixture builds a reception through Runtime")
|
||||
var jobs: Array = _runtime.construction_service.get_jobs()
|
||||
_expect(jobs.size() == 1, "fixture creates exactly one real construction site")
|
||||
if jobs.size() != 1:
|
||||
return
|
||||
var job: Variant = jobs[0]
|
||||
_preview.layer_mode = WorldPreview.LayerMode.FRONT
|
||||
_preview.set_zoom(2.0)
|
||||
_center_on(origin)
|
||||
_click(_preview.cell_to_canvas(origin))
|
||||
_expect(_last_construction == StringName(job.job_id) and _last_facility.is_empty(),
|
||||
"zoomed construction click resolves the real job rather than a finished facility")
|
||||
_clock.advance_minutes(120)
|
||||
var facilities: Array = _runtime.construction_service.get_facilities()
|
||||
_expect(facilities.size() == 1, "the clicked construction completes in simulation time")
|
||||
if facilities.size() != 1:
|
||||
return
|
||||
var facility_id := StringName(facilities[0].get(&"instance_id", &""))
|
||||
var occupied: Array[Vector3i] = []
|
||||
for x: int in range(4):
|
||||
for y: int in range(2):
|
||||
occupied.append(origin + Vector3i(x, y, 0))
|
||||
for zoom: float in [1.0, 2.0, 3.0]:
|
||||
_preview.set_zoom(zoom)
|
||||
_center_on(origin + Vector3i(1, 1, 0))
|
||||
for cell: Vector3i in occupied:
|
||||
var rect := _preview.get_cell_rect(cell)
|
||||
# Sample near opposite corners to catch square-cell conversions that
|
||||
# look correct at the center but select a different floor near edges.
|
||||
for fraction: Vector2 in [Vector2(0.15, 0.15), Vector2(0.85, 0.85)]:
|
||||
var point := rect.position + rect.size * fraction
|
||||
_expect(_preview.canvas_to_cell(point) == cell,
|
||||
"zoom %.1f maps interior point to occupied cell %s" % [zoom, cell])
|
||||
_expect(_preview.facility_at_canvas(point) == facility_id,
|
||||
"zoom %.1f resolves reception footprint at %s" % [zoom, cell])
|
||||
_click(point)
|
||||
_expect(_last_facility == facility_id and _last_construction.is_empty(),
|
||||
"zoom %.1f emits facility selection for the clicked interior" % zoom)
|
||||
var empty_neighbor := origin + Vector3i(4, 0, 0)
|
||||
var empty_point := _preview.cell_to_canvas(empty_neighbor)
|
||||
_click(empty_point)
|
||||
_expect(_last_facility.is_empty() and _last_cell == empty_neighbor,
|
||||
"zoom %.1f does not extend the reception hit area into the adjacent cell" % zoom)
|
||||
var front_point := _preview.cell_to_canvas(origin)
|
||||
_preview.layer_mode = WorldPreview.LayerMode.BACK
|
||||
_click(front_point)
|
||||
_expect(_last_facility.is_empty() and _last_cell == Vector3i(origin.x, origin.y, WorldPreview.LayerMode.BACK),
|
||||
"zoom %.1f cannot select the FRONT reception through the BACK layer" % zoom)
|
||||
_preview.layer_mode = WorldPreview.LayerMode.FRONT
|
||||
_click(_preview.cell_to_canvas(origin))
|
||||
_expect(_last_facility == facility_id,
|
||||
"zoom %.1f restores reception selection after returning to FRONT" % zoom)
|
||||
|
||||
|
||||
func _test_expanded_world_round_trips() -> void:
|
||||
var reputation: Variant = _runtime.submit_command(
|
||||
&"update_progression_metrics", {&"metrics": {&"reputation": 25}},
|
||||
&"player", &"", 100, -1, &"pixel.camera.rank.reputation"
|
||||
)
|
||||
var capital: Variant = _runtime.submit_command(
|
||||
&"change_resources", {&"deltas": {&"gold": 10000}},
|
||||
&"player", &"", 100, -1, &"pixel.camera.rank.capital"
|
||||
)
|
||||
var promoted: Variant = _runtime.submit_command(
|
||||
&"set_world_tree_rank", {&"rank": 2},
|
||||
&"player", &"", 100, -1, &"pixel.camera.rank.promote"
|
||||
)
|
||||
_expect(reputation.is_success() and capital.is_success() and promoted.is_success(),
|
||||
"public progression commands expand the camera fixture above and below ground")
|
||||
if not promoted.is_success():
|
||||
return
|
||||
var bounds: Dictionary = _runtime.world_service.get_bounds()
|
||||
var cells: Array[Vector3i] = [
|
||||
Vector3i.ZERO,
|
||||
Vector3i(0, -1, 0),
|
||||
Vector3i(int(bounds[&"min_x"]), int(bounds[&"min_y"]), 0),
|
||||
Vector3i(int(bounds[&"max_x"]), int(bounds[&"max_y"]), 0),
|
||||
Vector3i(-3, 4, 0),
|
||||
]
|
||||
for layer: int in [WorldPreview.LayerMode.FRONT, WorldPreview.LayerMode.BACK]:
|
||||
_preview.layer_mode = layer as WorldPreview.LayerMode
|
||||
for zoom: float in [1.0, 2.0, 3.0]:
|
||||
_preview.set_zoom(zoom)
|
||||
for cell: Vector3i in cells:
|
||||
cell.z = layer
|
||||
_center_on(cell)
|
||||
_preview.pan_offset += Vector2(31, -17)
|
||||
var point := _preview.cell_to_canvas(cell)
|
||||
_expect(_preview.canvas_to_cell(point) == cell,
|
||||
"zoom %.1f round trip survives pan, bounds, and layer at %s" % [zoom, cell])
|
||||
var rect := _preview.get_cell_rect(cell)
|
||||
_expect(rect.has_point(point), "cell center remains inside its rendered rectangle")
|
||||
_preview.reset_view()
|
||||
var overview := _preview.get_map_rect()
|
||||
_expect(_preview.canvas_to_cell(overview.position - Vector2.ONE * 2.0) == null,
|
||||
"outside-world pixels cannot become buildable cells after overview reset")
|
||||
|
||||
|
||||
func _test_grid_preserves_hit_testing() -> void:
|
||||
_preview.layer_mode = WorldPreview.LayerMode.FRONT
|
||||
var cell := Vector3i(-5, 1, 0)
|
||||
_center_on(cell)
|
||||
var point := _preview.cell_to_canvas(cell)
|
||||
var expected := _preview.facility_at_canvas(point)
|
||||
_expect(not expected.is_empty(), "grid fixture points at a real reception cell")
|
||||
for show: bool in [false, true, false]:
|
||||
_preview.show_grid = show
|
||||
_click(point)
|
||||
_expect(_last_facility == expected and _preview.canvas_to_cell(point) == cell,
|
||||
"grid visibility never changes facility or floor hit testing")
|
||||
|
||||
|
||||
func _test_animation_pause_and_simulation_isolation() -> void:
|
||||
var before_digest := SimulationDigest.compute(_runtime.create_snapshot())
|
||||
var before_minute: int = _clock.absolute_minute
|
||||
var stopped_at := float(_preview.get("_animation_time"))
|
||||
_preview.call("_process", 0.5)
|
||||
_expect(is_equal_approx(float(_preview.get("_animation_time")), stopped_at),
|
||||
"paused simulation freezes actor animation even when rendering receives a frame")
|
||||
_clock.set_paused(false)
|
||||
_preview.call("_process", 0.25)
|
||||
var animated_at := float(_preview.get("_animation_time"))
|
||||
_expect(animated_at > stopped_at, "unpausing resumes the pixel actor animation clock")
|
||||
_clock.set_paused(true)
|
||||
_preview.call("_process", 0.5)
|
||||
_expect(is_equal_approx(float(_preview.get("_animation_time")), animated_at),
|
||||
"pausing again freezes the exact resumed animation position")
|
||||
|
||||
var cell := Vector3i(-5, 1, 0)
|
||||
_center_on(cell)
|
||||
var anchor := _preview.cell_to_canvas(cell)
|
||||
var wheel := InputEventMouseButton.new()
|
||||
wheel.button_index = MOUSE_BUTTON_WHEEL_UP
|
||||
wheel.pressed = true
|
||||
wheel.position = anchor
|
||||
_preview.call("_gui_input", wheel)
|
||||
_expect(_preview.canvas_to_cell(anchor) == cell,
|
||||
"wheel input preserves the room under the cursor while paused")
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_MIDDLE
|
||||
press.pressed = true
|
||||
_preview.call("_gui_input", press)
|
||||
var drag := InputEventMouseMotion.new()
|
||||
drag.relative = Vector2(43, 27)
|
||||
_preview.call("_gui_input", drag)
|
||||
press.pressed = false
|
||||
_preview.call("_gui_input", press)
|
||||
var moved_point := _preview.cell_to_canvas(cell)
|
||||
_expect(moved_point.distance_to(anchor + drag.relative) <= 2.0,
|
||||
"middle-button drag translates the visible room by the pointer movement")
|
||||
_click(moved_point)
|
||||
_expect(not _last_facility.is_empty(), "a room remains selectable immediately after dragging")
|
||||
_preview.reset_view()
|
||||
_expect(int(_clock.absolute_minute) == before_minute,
|
||||
"animation frames and camera input do not advance simulation minutes")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == before_digest,
|
||||
"animation, zoom, pan, and visual selection preserve the authoritative simulation digest")
|
||||
|
||||
|
||||
func _center_on(cell: Vector3i) -> void:
|
||||
_preview.pan_offset += _preview.size * 0.5 - _preview.cell_to_canvas(cell)
|
||||
|
||||
|
||||
func _click(position: Vector2) -> void:
|
||||
_last_facility = &""
|
||||
_last_construction = &""
|
||||
_last_cell = null
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = true
|
||||
event.position = position
|
||||
event.global_position = _preview.global_position + position
|
||||
_preview.call("_gui_input", event)
|
||||
|
||||
|
||||
func _on_facility_selected(instance_id: StringName) -> void:
|
||||
_last_facility = instance_id
|
||||
|
||||
|
||||
func _on_construction_selected(job_id: StringName) -> void:
|
||||
_last_construction = job_id
|
||||
|
||||
|
||||
func _on_cell_clicked(cell: Vector3i) -> void:
|
||||
_last_cell = cell
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://btrbkobsgirwi
|
||||
Reference in New Issue
Block a user