class_name WorldPreview extends Control signal facility_selected(instance_id: StringName) signal staff_assignment_dropped(data: Dictionary, facility_id: StringName) signal construction_selected(job_id: StringName) signal legacy_stair_selected(stair_id: StringName) signal cell_clicked(cell: Vector3i) signal hovered_cell_changed(cell: Variant) signal build_preview_changed(report: Dictionary) signal excavation_preview_changed(report: Dictionary) enum LayerMode { FRONT, BACK, } const RoomPresentation := preload("res://scripts/ui/room_presentation.gd") const People := preload("res://scripts/ui/pixel_people.gd") const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd") const InactiveLayerRooms := preload("res://scripts/ui/inactive_layer_rooms.gd") const LAYER_LENS_SHADER := preload("res://assets/shaders/layer_lens.gdshader") const LAYER_TRANSITION_SECONDS := 0.55 const MAP_PADDING := Vector2(76.0, 70.0) const INVALID_PREVIEW := {&"can_build": false, &"error_codes": [], &"reasons": [], &"cells": []} const INVALID_EXCAVATION_PREVIEW := { &"ok": false, &"cells": [], &"cost": {}, &"build_minutes": 0, &"errors": [&"MISSING_DEPENDENCY"], } var layer_mode := LayerMode.FRONT: set(value): var previous := layer_mode layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode if previous != layer_mode and is_node_ready(): _start_layer_transition() if _hovered_cell != null: _hovered_cell = Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode) queue_redraw() _update_build_preview() _update_excavation_preview() var selected_entity_id: StringName = &"": set(value): selected_entity_id = value queue_redraw() var build_definition: Variant = null var build_rotation := 0 var excavation_mode := false var visual_theme: VisualThemeProfile = null var zoom_level := 1.0 var pan_offset := Vector2.ZERO var show_grid := false var pan_mode := false var _drawing := false var _draw_cache: Dictionary = {} var _inactive_layer_art: Node2D var _layer_lens: ColorRect var _layer_transition_elapsed := LAYER_TRANSITION_SECONDS var _animation_time := 0.0 var _animation_frame := -1 var _actor_tracks: Dictionary = {} var _elevator_tracks: Dictionary = {} var _boarded_actor_ids: Dictionary = {} var _carried_patients: Dictionary = {} var _treated_patients: Dictionary = {} var _resting_staff: Dictionary = {} var _room_presentations: Dictionary = {} var _queue_positions: Dictionary = {} var _queue_hidden: Dictionary = {} var _landing_badges: Array[Dictionary] = [] var _transport_report: Dictionary = {} var _dragging := false var _drag_button := MOUSE_BUTTON_NONE var _runtime: Variant = null var _hovered_cell: Variant = null var _preview_topology_key: Array = [] var _connection_cache_key: Array = [] var _connection_cache_report: Dictionary = {} var _last_preview_report: Dictionary = INVALID_PREVIEW.duplicate(true) var _last_excavation_preview_report: Dictionary = INVALID_EXCAVATION_PREVIEW.duplicate(true) func _ready() -> void: clip_contents = true texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST focus_mode = Control.FOCUS_CLICK mouse_filter = Control.MOUSE_FILTER_STOP resized.connect(queue_redraw) mouse_exited.connect(_on_mouse_exited) if _runtime == null: _runtime = get_node_or_null("/root/SimulationRuntime") _connect_runtime() _inactive_layer_art = InactiveLayerRooms.new() _inactive_layer_art.name = "InactiveLayerRooms" add_child(_inactive_layer_art) _layer_lens = ColorRect.new() _layer_lens.name = "LayerLens" _layer_lens.mouse_filter = Control.MOUSE_FILTER_IGNORE _layer_lens.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) var lens_material := ShaderMaterial.new() lens_material.shader = LAYER_LENS_SHADER _layer_lens.material = lens_material _layer_lens.visible = false add_child(_layer_lens) queue_redraw() func set_runtime(value: Variant) -> void: _connection_cache_key.clear() _connection_cache_report.clear() _disconnect_runtime() _runtime = value _actor_tracks.clear() _elevator_tracks.clear() _boarded_actor_ids.clear() _connect_runtime() queue_redraw() _update_build_preview() _update_excavation_preview() func set_visual_theme(profile: VisualThemeProfile) -> void: visual_theme = profile queue_redraw() func set_build_definition(definition: Variant, rotation := 0) -> void: if definition != null and excavation_mode: set_excavation_mode(false) build_definition = definition build_rotation = _normalized_rotation(rotation) _update_build_preview() queue_redraw() func clear_build_definition() -> void: build_definition = null build_rotation = 0 _last_preview_report = INVALID_PREVIEW.duplicate(true) build_preview_changed.emit(_last_preview_report.duplicate(true)) queue_redraw() func set_build_rotation(value: int) -> void: build_rotation = _normalized_rotation(value) _update_build_preview() queue_redraw() func set_excavation_mode(enabled: bool) -> void: if excavation_mode == enabled: _update_excavation_preview() return excavation_mode = enabled if excavation_mode and build_definition != null: build_definition = null build_rotation = 0 _last_preview_report = INVALID_PREVIEW.duplicate(true) build_preview_changed.emit(_last_preview_report.duplicate(true)) _update_excavation_preview() queue_redraw() func get_excavation_preview_report(cell: Variant = null) -> Dictionary: var target: Variant = _hovered_cell if cell == null else cell if not target is Vector3i: return INVALID_EXCAVATION_PREVIEW.duplicate(true) var excavation: Variant = _service(&"excavation_service") if excavation != null and excavation.has_method(&"validate_excavation"): var report: Variant = excavation.call( &"validate_excavation", Vector3i(target.x, target.y, layer_mode) ) if report is Dictionary: return report.duplicate(true) return INVALID_EXCAVATION_PREVIEW.duplicate(true) func get_hovered_cell() -> Variant: return _hovered_cell func clear_hovered_cell() -> void: _set_hovered_cell(null) func get_build_preview_report(cell: Variant = null) -> Dictionary: var target: Variant = _hovered_cell if cell == null else cell if build_definition == null or not target is Vector3i: return INVALID_PREVIEW.duplicate(true) var construction: Variant = _service(&"construction_service") if construction != null and construction.has_method(&"validate_placement"): var report: Variant = construction.call( &"validate_placement", build_definition, Vector3i(target.x, target.y, layer_mode), build_rotation ) if report != null and report.has_method(&"to_dictionary"): var result: Dictionary = report.call(&"to_dictionary") result[&"connection"] = _build_connection_report( _cells_from_serialized(result.get(&"cells", [])), Vector3i(target.x, target.y, layer_mode)) return result return { &"can_build": _fallback_cells_are_available(Vector3i(target.x, target.y, layer_mode)), &"error_codes": [], &"reasons": [], &"cells": _serialize_cells(_fallback_build_cells(Vector3i(target.x, target.y, layer_mode))), } func get_map_rect() -> Rect2: if _drawing and _draw_cache.has(&"rect"): return _draw_cache[&"rect"] var bounds := _world_bounds() if bounds.is_empty(): return Rect2() var dimensions := _cell_dimensions() var map_size := Vector2( int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1 ) * dimensions var result_value = Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size) if _drawing: _draw_cache[&"rect"] = result_value return result_value func _cell_dimensions() -> Vector2: if _drawing and _draw_cache.has(&"dimensions"): return _draw_cache[&"dimensions"] var bounds := _world_bounds() if bounds.is_empty(): return Vector2(32, 48) var columns := maxi(int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, 1) var rows := maxi(int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1, 1) var available := (size - MAP_PADDING * 2.0).max(Vector2.ONE) var fit := minf(available.x / (columns * 16.0), available.y / (rows * 24.0)) # Native 16px units use integer magnification. Large worlds can be viewed # at overview scale, then enlarged with the same invertible camera mapping. var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25) var result_value = (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6)) if _drawing: _draw_cache[&"dimensions"] = result_value return result_value func set_zoom(value: float, anchor := Vector2(-1, -1)) -> void: var before := get_map_rect() var at := size * 0.5 if anchor.x < 0 else anchor var relative := (at - before.position) / before.size if before.has_area() else Vector2(0.5, 0.5) zoom_level = clampf(value, 0.5, 4.0) var after := get_map_rect() pan_offset += at - (after.position + relative * after.size) _clamp_pan() _set_hovered_cell(canvas_to_cell(at)) queue_redraw() func reset_view() -> void: zoom_level = 1.0 pan_offset = Vector2.ZERO _actor_tracks.clear() queue_redraw() func focus_operating_area() -> void: # Explicit framing, never continuous tracking: the player's pan stays put. zoom_level = 1.0 pan_offset = Vector2.ZERO var area := Rect2() var construction: Variant = _service(&"construction_service") if construction != null: for item: Dictionary in construction.get_facilities(): if int(item.get(&"layer", 0)) != layer_mode: continue var cells := _cells_from_serialized(item.get(&"occupied_cells", [])) if cells.is_empty(): continue var rect := _cell_bounds_rect(cells) area = area.merge(rect) if area.has_area() else rect if area.has_area(): var relative := (area.get_center()-get_map_rect().position) / get_map_rect().size zoom_level = clampf(minf(size.x*0.68/area.size.x, size.y*0.65/area.size.y), 0.5, 2.0) var rect := get_map_rect() pan_offset += size*Vector2(0.5, 0.58) - (rect.position+rect.size*relative) else: zoom_level = 2.0 pan_offset += size*Vector2(0.5, 0.65) - cell_to_canvas(Vector3i(0, 1, layer_mode)) _clamp_pan() clear_hovered_cell() queue_redraw() func set_pan_mode(enabled: bool) -> void: pan_mode = enabled _end_pan_drag() clear_hovered_cell() func _end_pan_drag() -> void: _dragging = false _drag_button = MOUSE_BUTTON_NONE mouse_default_cursor_shape = Control.CURSOR_DRAG if pan_mode else Control.CURSOR_ARROW func _notification(what: int) -> void: if what == NOTIFICATION_APPLICATION_FOCUS_OUT: _end_pan_drag() func _clamp_pan() -> void: var extent := get_map_rect().size * 0.5 + size * 0.3 pan_offset = pan_offset.clamp(-extent, extent) func _start_layer_transition() -> void: if _layer_lens == null: return _layer_transition_elapsed = 0.0 var center := size * 0.5 if _hovered_cell != null: center = cell_to_canvas(_hovered_cell) var lens_material := _layer_lens.material as ShaderMaterial lens_material.set_shader_parameter("center", center / size.max(Vector2.ONE)) lens_material.set_shader_parameter("extent", size) lens_material.set_shader_parameter("progress", 0.0) _layer_lens.visible = true func _process(delta: float) -> void: # UI transitions complete even in active pause; actor time remains frozen. if _layer_lens != null and _layer_lens.visible: _layer_transition_elapsed = minf(_layer_transition_elapsed + maxf(delta, 0.0), LAYER_TRANSITION_SECONDS) var lens_material := _layer_lens.material as ShaderMaterial lens_material.set_shader_parameter("extent", size) lens_material.set_shader_parameter("progress", _layer_transition_elapsed / LAYER_TRANSITION_SECONDS) _layer_lens.visible = _layer_transition_elapsed < LAYER_TRANSITION_SECONDS if build_definition != null and _hovered_cell is Vector3i and _current_preview_topology() != _preview_topology_key: _update_build_preview() queue_redraw() var clock := get_node_or_null("/root/GameClock") if clock != null and bool(clock.get("paused")): return var speed := float(clock.get("speed")) if clock != null else 1.0 _animation_time += delta * maxf(speed, 0.0) var frame := int(_animation_time * 12.0) if frame != _animation_frame: _animation_frame = frame queue_redraw() func _unhandled_key_input(event: InputEvent) -> void: if not event is InputEventKey or not event.pressed or event.echo: return if event.keycode == KEY_HOME: reset_view() get_viewport().set_input_as_handled() elif event.keycode == KEY_G: show_grid = not show_grid queue_redraw() get_viewport().set_input_as_handled() func cell_to_canvas(cell: Vector3i) -> Vector2: var bounds := _world_bounds() if bounds.is_empty(): return Vector2.ZERO return get_map_rect().position + Vector2( float(cell.x - int(bounds[&"min_x"])) + 0.5, float(int(bounds[&"max_y"]) - cell.y) + 0.5 ) * _cell_dimensions() func canvas_to_cell(canvas_position: Vector2) -> Variant: var bounds := _world_bounds() var map_rect := get_map_rect() if bounds.is_empty() or not map_rect.has_area() or not map_rect.has_point(canvas_position): return null var local_position := (canvas_position - map_rect.position) / _cell_dimensions() var cell := Vector3i(int(bounds[&"min_x"]) + floori(local_position.x), int(bounds[&"max_y"]) - floori(local_position.y), layer_mode) var world: Variant = _service(&"world_service") if world != null and world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)): return null return cell func get_cell_rect(cell: Vector3i) -> Rect2: if _world_bounds().is_empty(): return Rect2() var dimensions := _cell_dimensions() return Rect2(cell_to_canvas(cell) - dimensions * 0.5, dimensions) func facility_at_canvas(canvas_position: Vector2) -> StringName: var cell: Variant = canvas_to_cell(canvas_position) if not cell is Vector3i: return &"" var world: Variant = _service(&"world_service") if world == null or not world.has_method(&"get_occupant"): return &"" var occupant_id := StringName(world.call(&"get_occupant", cell)) if occupant_id.is_empty(): return &"" var kind := &"" if world.has_method(&"get_occupant_kind"): kind = StringName(world.call(&"get_occupant_kind", cell)) return occupant_id if kind == &"facility" else &"" func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: if not event.pressed and event.button_index == _drag_button: _end_pan_drag() accept_event() return if event.button_index == MOUSE_BUTTON_MIDDLE or (pan_mode and event.button_index == MOUSE_BUTTON_LEFT): if event.pressed: _dragging = true _drag_button = event.button_index mouse_default_cursor_shape = Control.CURSOR_DRAG accept_event() return if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]: if pan_mode and not event.ctrl_pressed: var direction := 1.0 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -1.0 pan_offset.y += direction * _cell_dimensions().y * maxf(event.factor, 0.0) _clamp_pan() queue_redraw() else: set_zoom(zoom_level + (0.5 if event.button_index == MOUSE_BUTTON_WHEEL_UP else -0.5), event.position) accept_event() return if event is InputEventMouseMotion and _dragging: var mask := MOUSE_BUTTON_MASK_LEFT if _drag_button == MOUSE_BUTTON_LEFT else MOUSE_BUTTON_MASK_MIDDLE if not event.button_mask & mask: _end_pan_drag() return pan_offset += event.relative _clamp_pan() queue_redraw() accept_event() return if event is InputEventMouseMotion: if not pan_mode: _set_hovered_cell(canvas_to_cell(event.position)) return if not event is InputEventMouseButton or not event.pressed or event.button_index != MOUSE_BUTTON_LEFT: return var cell: Variant = canvas_to_cell(event.position) if not cell is Vector3i: return if build_definition != null or excavation_mode: cell_clicked.emit(cell) accept_event() return var stair_id := legacy_stair_at_canvas(event.position) if not stair_id.is_empty(): selected_entity_id = stair_id legacy_stair_selected.emit(stair_id) accept_event() return var world: Variant = _service(&"world_service") if world != null and world.has_method(&"get_occupant"): var occupant_id := StringName(world.call(&"get_occupant", cell)) var kind := &"" if world.has_method(&"get_occupant_kind"): kind = StringName(world.call(&"get_occupant_kind", cell)) if not occupant_id.is_empty() and kind == &"facility": selected_entity_id = occupant_id facility_selected.emit(occupant_id) accept_event() return if not occupant_id.is_empty() and kind == &"construction": selected_entity_id = occupant_id construction_selected.emit(occupant_id) accept_event() return cell_clicked.emit(cell) accept_event() func _draw() -> void: _drawing = true _draw_cache.clear() _draw_contents() _drawing = false _draw_cache.clear() func _draw_contents() -> void: if _inactive_layer_art != null: _inactive_layer_art.visible = false _draw_background() var bounds := _world_bounds() if bounds.is_empty(): _draw_center_message("世界データを待っています") return var map_rect := get_map_rect() if map_rect.size == Vector2.ZERO: return _draw_world_cells(bounds, map_rect) if show_grid: _draw_congestion_overlay(bounds) _prepare_room_presentations() _prepare_queues() _draw_world_entity_icons() _draw_legacy_stairs() _draw_elevators() _draw_other_layer() _draw_construction_labels() _draw_facility_labels() _draw_actors() _draw_excavation_jobs() _draw_build_preview() _draw_excavation_preview() _draw_hover() _draw_map_caption(map_rect) func _draw_background() -> void: var night := false var clock := get_node_or_null("/root/GameClock") if clock != null: var hour := int(clock.call("get_hour")) night = hour >= 18 or hour < 6 var sky := Color("b5cbb5") if not night else Color("273c49") if layer_mode == LayerMode.BACK: sky = Color("526d68") if not night else Color("233d40") draw_rect(Rect2(Vector2.ZERO, size), sky) # Pixel silhouettes make the world frame distinct from the usable floors. var horizon := size.y * 0.65 for band in range(3): var color := [Color("8cae98"), Color("749981"), Color("567e68")][band] as Color if night: color = color.darkened(0.48) for x in range(-24, int(size.x)+64, 48): var crown_y := horizon + band*46 - posmod(x*17+band*29, 83) draw_rect(Rect2(x, crown_y, 48, size.y-crown_y), color) draw_rect(Rect2(x+8, crown_y-12, 32, 12), color) if night: for i in range(28): var at := Vector2(posmod(i*137+31, maxi(int(size.x),1)), posmod(i*47+13,maxi(int(horizon),1))) draw_rect(Rect2(at,Vector2(2,2)),Color("c6d3b6")) else: for i in range(5): var x := float(posmod(i*263+41, maxi(int(size.x),1))) var y := 32.0+float(i%3)*27.0 draw_rect(Rect2(x,y,86,12),Color("d5dec4")) draw_rect(Rect2(x+17,y-7,46,7),Color("d5dec4")) func _draw_world_cells(bounds: Dictionary, map_rect: Rect2) -> void: var world: Variant = _service(&"world_service") if world == null: return var p := _cell_dimensions().x / 16.0 var ground := get_cell_rect(Vector3i(0,0,layer_mode)).end.y var trunk := Rect2(map_rect.position-Vector2(9*p,22*p), map_rect.size+Vector2(18*p,24*p)) draw_rect(trunk, Color("574d39")) draw_rect(trunk.grow(-2*p), Color("776445")) for x in range(int(trunk.position.x+4*p),int(trunk.end.x),maxi(int(7*p),1)): var notch := float(posmod(x*11,17))*p draw_rect(Rect2(x,trunk.position.y+notch,2*p,trunk.size.y-notch),Color("8a7350")) # Branches, crown and roots belong to the scenery, never facility slots. for side in [-1,1]: var edge := trunk.position.x if side < 0 else trunk.end.x for branch in range(3): var y := trunk.position.y+(branch*24+22)*p var x := edge-(18+branch*4)*p if side<0 else edge draw_rect(Rect2(x,y,(18+branch*4)*p,5*p),Color("69583d")) _draw_leaf_cluster(Vector2(x+(2 if side<0 else 14)*p,y-5*p),p,branch) var root_x := edge-28*p if side<0 else edge-2*p draw_rect(Rect2(root_x,ground-3*p,30*p,6*p),Color("65583e")) draw_rect(Rect2(root_x+(10*p if side<0 else 0),ground-7*p,20*p,5*p),Color("776445")) draw_rect(Rect2(trunk.position.x-12*p,trunk.position.y-14*p,trunk.size.x+24*p,17*p),Color("385d45")) for i in range(17): var arch := sin(float(i)/16.0*PI)*17.0 _draw_leaf_cluster(Vector2(trunk.position.x+float(i)*trunk.size.x/16.0,trunk.position.y-4*p-arch*p-float(i%3)*3*p),p,i) # Unbuilt space stays dark wood; completed rooms cut into it below. for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"])+1): for x in range(int(bounds[&"min_x"]),int(bounds[&"max_x"])+1): var cell := Vector3i(x,y,layer_mode) var rect := get_cell_rect(cell) if not rect.intersects(Rect2(Vector2.ZERO,size)): continue var excavated := bool(world.call(&"is_excavated",cell)) if world.has_method(&"is_excavated") else true if not excavated: draw_rect(rect,Color("47463e")) if posmod(x+y,3)==0: draw_rect(Rect2(rect.position+Vector2(5,11)*p,Vector2(5,2)*p),Color("5a594b")) elif bool(world.call(&"is_walkable",cell)): draw_rect(rect.grow(-p), Color("aaa083") if layer_mode==LayerMode.FRONT else Color("829a8d")) draw_rect(Rect2(rect.position.x,rect.end.y-4*p,rect.size.x,3*p),Color("b69465")) draw_rect(Rect2(rect.position.x,rect.end.y-p,rect.size.x,p),Color("514839")) if posmod(x,4)==0: PixelArt.window(self,rect.position+Vector2(5,4)*p,p) var kind := StringName(world.call(&"get_occupant_kind",cell)) if kind==&"construction": draw_rect(rect.grow(-p),Color("8d7951")) _draw_hatch(rect.grow(-p),Color("e1bd6c"),rect.size.x) if show_grid or build_definition != null or excavation_mode: draw_rect(rect,Color("dfd4ad",0.25),false,1.0) var label_at := Vector2(map_rect.position.x-32*p, get_cell_rect(Vector3i(0,y,layer_mode)).get_center().y) var floor_name := "%dF" % (y+1) if y>=0 else "B%d" % -y draw_rect(Rect2(label_at-Vector2(2,8)*p,Vector2(22,12)*p),Color("2f493f")) draw_string(ThemeDB.fallback_font,label_at+Vector2(0,1)*p,floor_name,HORIZONTAL_ALIGNMENT_CENTER,18*p,clampi(int(7*p),10,18),Color("e4d9b9")) # A narrow garden frames the entrance without hiding underground floors. draw_rect(Rect2(map_rect.position.x-35*p,ground,map_rect.size.x+70*p,3*p),Color("58784c")) var town := visual_theme.pixel_town_tiles if visual_theme != null else null for side in [-1,1]: var gx := map_rect.position.x-32*p if side<0 else map_rect.end.x+15*p PixelArt.tile(self,town,4,Rect2(gx,ground-30*p,16*p,16*p)) PixelArt.tile(self,town,16,Rect2(gx,ground-14*p,16*p,16*p)) PixelArt.tile(self,town,2,Rect2(gx-12*p,ground-8*p,12*p,12*p)) func _draw_leaf_cluster(center: Vector2, p: float, variation: int) -> void: var c := center.round() var shift := float(variation%3)*2.0 draw_rect(Rect2(c-Vector2(24,5)*p,Vector2(48,12)*p),Color("385d45")) draw_rect(Rect2(c-Vector2(20,10)*p,Vector2(40,20)*p),Color("4c7950")) draw_rect(Rect2(c-Vector2(13+shift,15)*p,Vector2(29,25)*p),Color("4c7950")) draw_rect(Rect2(c-Vector2(22,5)*p,Vector2(37,10)*p),Color("5e8b57")) draw_rect(Rect2(c-Vector2(11+shift,13)*p,Vector2(23,7)*p),Color("739957")) for i in range(9): var offset := Vector2(posmod(i*11+variation*3,38)-19,posmod(i*7+variation,21)-11)*p draw_rect(Rect2(c+offset,Vector2(3+i%2,2)*p),Color("95ad69") if i%3==0 else Color("648957")) func _draw_congestion_overlay(bounds: Dictionary) -> void: var path_service: Variant = _service(&"path_service") if path_service == null or not path_service.has_method(&"get_cell_traffic"): return for y in range(int(bounds[&"min_y"]), int(bounds[&"max_y"]) + 1): for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1): var cell := Vector3i(x, y, layer_mode) var traffic_value: Variant = path_service.call(&"get_cell_traffic", cell) if not traffic_value is Dictionary or traffic_value.is_empty(): continue var traffic: Dictionary = traffic_value var density := int(traffic.get(&"current_density", 0)) var capacity := maxi(int(traffic.get(&"base_capacity", 1)), 1) var blocked := bool(traffic.get(&"temporary_block", false)) if density <= 0 and not blocked: continue var ratio := float(density) / float(capacity) var color := Color("55c96b") if blocked or ratio > 1.0: color = Color("e34332") elif ratio >= 0.65: color = Color("e3a62f") var rect := get_cell_rect(cell).grow(-2.0) var center := rect.get_center() var route_width := clampf(rect.size.y * 0.16, 2.0, 5.0) draw_line( Vector2(rect.position.x + rect.size.x * 0.12, center.y), Vector2(rect.end.x - rect.size.x * 0.12, center.y), Color(color, 0.90), route_width ) if blocked: draw_line(rect.position, rect.end, color, 2.0) draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0) ## Visual-only level comes from the existing live FacilityInstance, not the ## construction footprint dictionary. No upgrade or simulation value is written. func _facility_visual_level(instance_id: StringName) -> int: var service: Variant = _service(&"facility_service") if service == null or not service.has_method(&"get_instance"): return 1 var instance: Variant = service.get_instance(instance_id) return int(instance.level) if instance != null else 1 func _completed_facilities_for_draw() -> Array: if _drawing and _draw_cache.has(&"completed_facilities"): return _draw_cache[&"completed_facilities"] var construction: Variant = _service(&"construction_service") var facilities: Array = construction.get_facilities() if construction != null else [] if _drawing: _draw_cache[&"completed_facilities"] = facilities return facilities func _construction_jobs_for_draw() -> Array: if _drawing and _draw_cache.has(&"construction_jobs"): return _draw_cache[&"construction_jobs"] var construction: Variant = _service(&"construction_service") var jobs: Array = construction.get_jobs() if construction != null else [] if _drawing: _draw_cache[&"construction_jobs"] = jobs return jobs func _other_layer_rooms() -> Array[Dictionary]: var rooms: Array[Dictionary] = [] for facility: Dictionary in _completed_facilities_for_draw(): if int(facility.get(&"layer", 0)) == layer_mode: continue var cells := _cells_from_serialized(facility.get(&"occupied_cells", [])) if cells.is_empty(): continue var rect := _cell_bounds_rect(cells) if rect.intersects(Rect2(Vector2.ZERO, size)): rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x, &"level": _facility_visual_level(StringName(facility[&"instance_id"]))}) for job: ConstructionJob in _construction_jobs_for_draw(): if job.layer == layer_mode or job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue if job.reserved_cells.is_empty(): continue var rect := _cell_bounds_rect(job.reserved_cells) if rect.intersects(Rect2(Vector2.ZERO, size)): rooms.append({&"id":job.job_id, &"rect":rect, &"definition_id":job.definition_id, &"origin_x":job.origin.x, &"building":true, &"progress":job.progress}) for stair: Dictionary in get_legacy_stair_records(): var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] if upper.z == layer_mode: continue var rect := _legacy_stair_rect(upper) if rect.intersects(Rect2(Vector2.ZERO,size)): rooms.append({&"id":stair[&"id"], &"rect":rect, &"legacy_stair":true, &"definition_id":&"service_stairs" if upper.z==LayerMode.BACK else &"guest_stairs", &"origin_x":upper.x}) return rooms func _draw_other_layer() -> void: # Opposite-layer footprints are visual context only. Hit testing still uses # the selected layer's WorldService occupancy, including overlapping rooms. var rooms := _other_layer_rooms() if _inactive_layer_art != null: _inactive_layer_art.visible = layer_mode == LayerMode.FRONT _inactive_layer_art.set("rooms", rooms) _inactive_layer_art.set("pixel_scale", _cell_dimensions().x / 16.0) _inactive_layer_art.set("dungeon", visual_theme.pixel_dungeon_tiles if visual_theme != null else null) _inactive_layer_art.set("town", visual_theme.pixel_town_tiles if visual_theme != null else null) _inactive_layer_art.queue_redraw() for room: Dictionary in rooms: var rect: Rect2 = room[&"rect"] if bool(room.get(&"building", false)): var color := Color(0.95,0.76,0.35,0.45) draw_rect(rect.grow(-2),color,false,1.0) _draw_hatch(rect.grow(-3),Color(color,0.24),_cell_dimensions().x) _draw_readable_badge(rect.position+Vector2(3,3),"%s 工事%d%%" % ["裏" if layer_mode==LayerMode.FRONT else "表",clampi(int(room[&"progress"])/100,0,100)]) continue if layer_mode == LayerMode.FRONT: draw_rect(rect.grow(-2), Color(0.6, 0.87, 0.9, 0.45), false, 1.0) else: var color := Color(0.94, 0.83, 0.54, 0.55) draw_rect(rect.grow(-2), color, false, 1.0) draw_line(rect.position+Vector2(3,3), rect.end-Vector2(3,3), Color(color,0.2), 1.0) func _draw_world_entity_icons() -> void: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_facilities"): return var p := _cell_dimensions().x / 16.0 var dungeon := visual_theme.pixel_dungeon_tiles if visual_theme != null else null var town := visual_theme.pixel_town_tiles if visual_theme != null else null for value: Variant in _completed_facilities_for_draw(): if not value is Dictionary or int(value.get(&"layer",0)) != layer_mode: continue var facility: Dictionary = value var cells := _cells_from_serialized(facility.get(&"occupied_cells",[])) if cells.is_empty(): continue var rect := _cell_bounds_rect(cells) if not rect.intersects(Rect2(Vector2.ZERO,size)): continue var id := StringName(facility.get(&"definition_id",&"")) PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {}), _facility_visual_level(StringName(facility.get(&"instance_id", &"")))) var instance_id := StringName(facility.get(&"instance_id",&"")) # Thin status lamp conveys operational state without repainting a room. draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id)) if instance_id==selected_entity_id: draw_rect(rect.grow(-p),Color("f5d582"),false,2*p) var data: Dictionary = _room_presentations.get(instance_id, {}) if not data.is_empty() and (rect.size.x >= 80 or instance_id == selected_entity_id) and id not in [&"guest_corridor", &"service_corridor", &"guest_stairs", &"service_stairs", &"guest_elevator", &"service_elevator"]: var status := RoomPresentation.label(data, RoomPresentation.slots(rect, p, id).size()) _draw_readable_badge(rect.position+Vector2(2, 2), status.replace("利用", "治療") if id == &"infirmary" else status.replace("利用", "休憩") if id in [&"staff_waiting_room", &"staff_dormitory"] else status) func _draw_readable_badge(at: Vector2, text: String) -> void: var width := ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x+6 var rect := Rect2(at, Vector2(width, 14)) if not rect.intersects(Rect2(Vector2.ZERO, size)): return draw_rect(rect, Color("34413b", 0.94)) draw_string(ThemeDB.fallback_font, at+Vector2(3, 11), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color("fff2ce")) func _prepare_room_presentations() -> void: _room_presentations.clear() _carried_patients.clear() _treated_patients.clear() _resting_staff.clear() var service: Variant = _service(&"facility_service") if service == null: return for instance: Variant in service.get_instances(): if instance.origin.z == layer_mode: _room_presentations[instance.instance_id] = RoomPresentation.describe(instance) var staff: Variant = _service(&"staff_service") if staff != null: if _runtime is Object and _runtime.has_method("get_staff_rest_report"): for member: StaffRuntime in staff.get_staff_members(): if member.state != StaffRuntime.State.OFF_DUTY: continue var rest: Dictionary = _runtime.get_staff_rest_report(member.staff_id) if not rest.get(&"resting", false): continue _resting_staff[member.staff_id] = rest[&"facility_id"] var room: Dictionary = _room_presentations.get(rest[&"facility_id"], {}) if not room.is_empty(): room[&"people"].append(member.staff_id) room[&"count"] += 1 for task: StaffTask in staff.get_tasks(): if task.task_type != &"rescue_transport" or task.is_terminal() or task.assigned_staff_id.is_empty(): continue if task.state == StaffTask.State.WORKING: _treated_patients[task.target_entity_id] = task.rescue_facility_id var data: Dictionary = _room_presentations.get(task.rescue_facility_id, {}) if not data.is_empty(): data[&"people"].append(task.target_entity_id) data[&"count"] += 1 else: _carried_patients[task.target_entity_id] = task.assigned_staff_id func _prepare_queues() -> void: _queue_positions.clear() _queue_hidden.clear() _landing_badges.clear() _transport_report.clear() var groups: Dictionary = {} var facilities: Variant = _service(&"facility_service") var guests: Variant = _service(&"guest_service") if facilities != null and guests != null: for instance: Variant in facilities.get_instances(): for request_id: StringName in instance.queue_entries: var request: Variant = facilities.get_request(request_id) if request == null: continue for guest_id: StringName in request.guest_ids: var guest: Variant = guests.get_guest(guest_id) if guest != null and guest.state == GuestRuntime.State.QUEUING: _add_queue_member(groups, "guest/"+String(guest_id), guest.current_cell) var transport: Variant = _service(&"elevators") if transport != null: _transport_report = transport.get_report() var trips: Array = _transport_report.get(&"trips", []) trips.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a[&"enqueued_at"]) < int(b[&"enqueued_at"]) or (a[&"enqueued_at"] == b[&"enqueued_at"] and String(a[&"actor_id"]) < String(b[&"actor_id"]))) for trip: Dictionary in trips: if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: var cell: Variant = trip[&"from"] if cell is Array: cell = Vector3i(cell[0], cell[1], cell[2]) _add_queue_member(groups, _elevator_actor_key(String(trip[&"actor_id"])), cell) var lift_cells: Dictionary = {} for shaft: Dictionary in _transport_report.get(&"shafts", []): for stop: Vector3i in _cells_from_serialized(shaft.get(&"stops", [])): lift_cells[stop] = true var reserved: Dictionary = {} var cells: Array = groups.keys() cells.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return a.y < b.y or (a.y == b.y and a.x < b.x)) for cell: Vector3i in cells: var members: Array = groups[cell] var lane: Array[Vector3i] = [] if not lift_cells.has(cell): lane.append(cell) var world: Variant = _service(&"world_service") for direction: int in [-1, 1]: for distance: int in range(1, 9): var next := cell + Vector3i(direction*distance, 0, 0) if world == null or not world.is_walkable(next): break var occupant: StringName = world.get_occupant(next) var facility: Variant = facilities.get_instance(occupant) if facilities != null else null if facility != null and facility.definition_id not in [&"guest_corridor", &"service_corridor"]: break lane.append(next) var index := 0 for target: Vector3i in lane: if index >= members.size(): break if reserved.has(target): continue reserved[target] = true _queue_positions[members[index]] = cell_to_canvas(target) index += 1 for hidden: int in range(index, members.size()): _queue_hidden[members[hidden]] = true var text := "待ち%d人" % members.size() if index < members.size(): text += "(他%d人)" % (members.size()-index) _landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text}) func _add_queue_member(groups: Dictionary, key: String, cell: Vector3i) -> void: if cell.z != layer_mode: return if not groups.has(cell): groups[cell] = [] if not groups[cell].has(key): groups[cell].append(key) func get_legacy_stair_records() -> Array[Dictionary]: if _drawing and _draw_cache.has(&"legacy_stair_records"): return _draw_cache[&"legacy_stair_records"] var result: Array[Dictionary] = [] var service: Variant = _service(&"legacy_stairs") if service == null or not service.has_method(&"create_snapshot"): return result var snapshot: Variant = service.call(&"create_snapshot") if not snapshot is Dictionary: return result for row: Variant in snapshot.get(&"stairs", []): if row is Dictionary and row.get(&"from") is Vector3i and row.get(&"to") is Vector3i: var record: Dictionary = row.duplicate(true) var path: Variant = _service(&"path_service") var connector: Dictionary = path.call(&"get_connector", StringName(row[&"id"])) if path != null else {} record[&"access_id"] = connector.get(&"access_id", &"") result.append(record) if _drawing: _draw_cache[&"legacy_stair_records"] = result return result func legacy_stair_at_canvas(canvas_position: Vector2) -> StringName: for stair: Dictionary in get_legacy_stair_records(): var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] if upper.z == layer_mode and _legacy_stair_rect(upper).has_point(canvas_position): return StringName(stair[&"id"]) return &"" func _legacy_stair_rect(upper: Vector3i) -> Rect2: var rect := get_cell_rect(upper) # A small target leaves the rest of an existing room available for selection. return Rect2(rect.position+rect.size*Vector2(0.49,0.24), rect.size*Vector2(0.5,0.7)) func _draw_legacy_stairs() -> void: for stair: Dictionary in get_legacy_stair_records(): var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] if upper.z != layer_mode: continue var rect := _legacy_stair_rect(upper) if not rect.intersects(Rect2(Vector2.ZERO, size)): continue var p := rect.size.x/14.0 draw_rect(rect, Color("3c4e40",0.5)) PixelArt.stairs(self,rect,p,upper.z == LayerMode.BACK,true) if StringName(stair[&"id"]) == selected_entity_id: draw_rect(rect,Color("f5d582"),false,maxf(p,1)) func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2: if cells.is_empty(): return Rect2() var result := get_cell_rect(cells[0]) for index: int in range(1, cells.size()): result = result.merge(get_cell_rect(cells[index])) return result func _draw_hatch(rect: Rect2, color: Color, cell_size: float) -> void: if cell_size < 8.0: return var offset := -rect.size.y while offset < rect.size.x: var start := Vector2(rect.position.x + maxf(offset, 0.0), rect.end.y - maxf(-offset, 0.0)) var finish := Vector2(rect.position.x + minf(offset + rect.size.y, rect.size.x), rect.end.y - minf(rect.size.y, rect.size.x - offset)) draw_line(start, finish, color, 1.0) offset += maxf(6.0, cell_size * 0.45) func _draw_construction_labels() -> void: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_jobs"): return var font := ThemeDB.fallback_font for job: Variant in _construction_jobs_for_draw(): if job == null or int(job.layer) != layer_mode or int(job.state) in [2, 3]: continue var rect := get_cell_rect(job.origin) if rect.size.x < 12.0: continue var percent := clampi(int(job.progress) / 100, 0, 100) draw_string(font, rect.position + Vector2(3, -3), "%d%%" % percent, HORIZONTAL_ALIGNMENT_LEFT, -1, clampi(int(rect.size.x * 0.45), 9, 13), Color("f5d58a")) func _draw_facility_labels() -> void: var construction: Variant = _service(&"construction_service") var registry: Variant = _service(&"definition_registry") if construction == null or not construction.has_method(&"get_facilities"): return for value: Variant in _completed_facilities_for_draw(): if not value is Dictionary or int(value.get(&"layer",0))!=layer_mode: continue var facility: Dictionary = value var cells := _cells_from_serialized(facility.get(&"occupied_cells",[])) if cells.is_empty(): continue var rect := _cell_bounds_rect(cells) var id := StringName(facility.get(&"definition_id",&"")) var hovered := _hovered_cell is Vector3i and cells.has(_hovered_cell) var selected := StringName(facility.get(&"instance_id",&""))==selected_entity_id if not hovered and not selected and rect.size.x<100: continue if id in [&"guest_corridor",&"service_corridor",&"staff_door",&"guest_stairs",&"service_stairs"] and not hovered and not selected: continue var definition: Variant = registry.call(&"get_facility",id) if registry != null else null var label := String(definition.display_name) if definition != null else String(id) var font_size := 12 var label_width := minf(ThemeDB.fallback_font.get_string_size(label,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size).x+12,rect.size.x) var at := Vector2(rect.get_center().x-label_width*0.5,rect.position.y-17) draw_rect(Rect2(at,Vector2(label_width,16)),Color("304b41",0.95)) draw_string(ThemeDB.fallback_font,at+Vector2(4,12),label,HORIZONTAL_ALIGNMENT_CENTER,label_width-8,font_size,Color("f0e2b9")) func _draw_elevators() -> void: _boarded_actor_ids.clear() var transport: Variant = _service(&"elevators") if transport == null or not transport.has_method(&"get_report"): _elevator_tracks.clear() return var report_value: Variant = _transport_report if not report_value is Dictionary: return var alive: Dictionary = {} var p := _cell_dimensions().x/16.0 for value: Variant in report_value.get(&"shafts", []): if not value is Dictionary: continue var shaft: Dictionary = value var stops := _cells_from_serialized(shaft.get(&"stops", [])) if stops.is_empty(): continue var shaft_id := StringName(shaft.get(&"shaft_id", &"")) alive[shaft_id] = true var passengers: Array = shaft.get(&"boarded", []) for actor_id: Variant in passengers: _boarded_actor_ids[_elevator_actor_key(String(actor_id))] = true var floor_value := float(shaft.get(&"visual_floor", shaft.get(&"current_floor", stops[0].y))) var visual_floor := _tracked_elevator_floor(shaft_id, floor_value) if stops[0].z != layer_mode: continue var origin := get_cell_rect(stops[0]) var width := maxi(int(shaft.get(&"width", 2)), 1) var baseline := origin.end.y-float(visual_floor-stops[0].y)*_cell_dimensions().y-3*p var cabin := Rect2(Vector2(origin.position.x+4*p,baseline-19*p).round(),Vector2(width*_cell_dimensions().x-8*p,19*p)) if not cabin.intersects(Rect2(Vector2.ZERO,size)): continue var operational := bool(shaft.get(&"operational", true)) var phase := StringName(shaft.get(&"phase", &"IDLE")) var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"] PixelArt.elevator_cabin(self,cabin,p,layer_mode==LayerMode.BACK,operational) var seat_columns := maxi(int((cabin.size.x / p - 4.0) / 8.0), 1) var visible_count := mini(passengers.size(), seat_columns*2) for index: int in range(visible_count): var actor_key := _elevator_actor_key(String(passengers[index])) var is_staff := actor_key.begins_with("staff/") var person: Variant = null var people: Variant = _service(&"staff_service" if is_staff else &"guest_service") var getter := &"get_staff" if is_staff else &"get_guest" if people != null and people.has_method(getter): person = people.call(getter,StringName(actor_key.get_slice("/",1))) var carrying := is_staff and person != null and not String(person.carried_inventory_id).is_empty() var state := int(person.state) if person != null else 0 var x := cabin.position.x + (2.0 + (index % seat_columns + 0.5)*8.0)*p var feet := cabin.end.y - (2.0 + int(index / seat_columns)*9.0)*p var center := Vector2(x, feet+3*p-_cell_dimensions().y*0.5) _draw_pixel_actor(center,actor_key,is_staff,state,false,carrying,&"",true) PixelArt.elevator_gate(self,cabin,p,doors_open) var capacity := int(shaft.get(&"capacity", 0)) var load_text := "%d/%d人" % [passengers.size(), capacity] if capacity > 0 and passengers.size() >= capacity: load_text = "満員 " + load_text if not operational: load_text = "停止 " + load_text _draw_readable_badge(Vector2(cabin.position.x, cabin.position.y-14), load_text) for badge: Dictionary in _landing_badges: _draw_readable_badge(badge[&"at"], badge[&"text"]) for shaft_id: Variant in _elevator_tracks.keys(): if not alive.has(shaft_id): _elevator_tracks.erase(shaft_id) func _elevator_actor_key(actor_id: String) -> String: if actor_id.begins_with("guest:"): return "guest/" + actor_id.trim_prefix("guest:") if actor_id.begins_with("staff:"): return "staff/" + actor_id.trim_prefix("staff:") return actor_id func _tracked_elevator_floor(shaft_id: StringName, current_floor: float) -> float: if not _elevator_tracks.has(shaft_id): _elevator_tracks[shaft_id] = {&"floor":current_floor,&"from":current_floor,&"at":_animation_time} var track: Dictionary = _elevator_tracks[shaft_id] if not is_equal_approx(float(track[&"floor"]),current_floor): track[&"from"] = float(track[&"floor"]) track[&"floor"] = current_floor track[&"at"] = _animation_time var clock := get_node_or_null("/root/GameClock") if clock != null and bool(clock.get("paused")): return current_floor var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0) return lerpf(float(track[&"from"]),current_floor,t) func _draw_actors() -> void: var counts: Dictionary = {} var alive: Dictionary = {} var service_slots: Dictionary = {} var guest_service: Variant = _service(&"guest_service") if guest_service != null and guest_service.has_method(&"get_guests"): for guest: Variant in guest_service.call(&"get_guests"): if guest == null: continue var key := "guest/"+String(guest.guest_id) alive[key] = true if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue var position := _tracked_actor_position(key,guest.current_cell) if guest.current_cell.z==layer_mode: var receiving := not String(guest.current_service_id).is_empty() if _queue_positions.has(key): position = _queue_positions[key] elif receiving: position = _service_use_position(guest,position,service_slots) if not position.is_finite(): continue else: position += _actor_offset(guest.current_cell,counts) _draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest)) var staff_service: Variant = _service(&"staff_service") if staff_service != null and staff_service.has_method(&"get_staff_members"): for staff: Variant in staff_service.call(&"get_staff_members"): if staff == null: continue var key := "staff/"+String(staff.staff_id) alive[key] = true if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue var position := _tracked_actor_position(key,staff.current_cell) if staff.current_cell.z==layer_mode: if _carried_patients.has(staff.staff_id): var carrier: Variant = staff_service.get_staff(_carried_patients[staff.staff_id]) if carrier != null: position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell) - Vector2(0, 12*_cell_dimensions().x/16.0) _draw_pixel_actor(position,key,true,int(staff.state),false,false) continue if _resting_staff.has(staff.staff_id): position = _resting_staff_position(staff.staff_id, position) if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) continue if _treated_patients.has(staff.staff_id): position = _rescue_treatment_position(staff.staff_id, position) if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) continue _draw_pixel_actor(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts))),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff)) for key: Variant in _actor_tracks.keys(): if not alive.has(key): _actor_tracks.erase(key) func _staff_work_animation(staff: StaffRuntime) -> StringName: if staff.role_id != &"cleaner": return &"work" # A staffed base is not a cleaning site. Animate only a live job at its # authoritative destination, including after load and interruption. var service: Variant = _service(&"staff_service") var bridge: Variant = _service(&"cleaning_bridge") var task: StaffTask = service.get_task(staff.current_task_id) if service != null else null if staff.state == StaffRuntime.State.WORKING and task != null and task.task_type == FacilityCleaningBridge.TASK_TYPE and task.state == StaffTask.State.WORKING and bridge != null and bridge.task_can_work(task, staff): return &"clean" return &"idle" func _staff_work_position(staff: Variant, fallback: Vector2) -> Vector2: if staff.state not in [StaffRuntime.State.WORKING, StaffRuntime.State.AVAILABLE]: return fallback var service: Variant = _service(&"facility_service") var facility: Variant = service.get_instance(staff.assigned_facility_id) if service != null else null if facility == null: return fallback var world: Variant = _service(&"world_service") var cells: Array[Vector3i] = world.get_entity_cells(staff.assigned_facility_id) if world != null else [] if not cells.has(staff.current_cell): return fallback var data: Dictionary = _room_presentations.get(staff.assigned_facility_id, {}) var workers: Array = data.get(&"staff", []) var slot := workers.find(staff.staff_id) if slot < 0: return fallback var rect := _cell_bounds_rect(cells) var p := _cell_dimensions().x / 16.0 if rect.size.y < 40*p: return fallback var columns := maxi(int(rect.size.x / (16*p)), 1) if slot >= columns: return fallback return Vector2(rect.position.x+(slot+0.5)*rect.size.x/columns, rect.position.y+26*p-_cell_dimensions().y*0.5) func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: if not _actor_tracks.has(key): _actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0} var track: Dictionary = _actor_tracks[key] var previous: Vector3i = track[&"cell"] if previous != cell: track[&"from"] = previous if previous.z==cell.z and absi(previous.x-cell.x)+absi(previous.y-cell.y)==1 else cell track[&"cell"] = cell track[&"at"] = _animation_time if previous.x!=cell.x: track[&"direction"] = 1.0 if cell.x>previous.x else -1.0 var clock := get_node_or_null("/root/GameClock") var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0) # Pausing displays the authoritative cell, never an ambiguous half-step. if clock != null and bool(clock.get("paused")): t = 1.0 return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t) func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary) -> Vector2: # Guest paths stop at an access cell. While a service is active, project the # occupant into that facility's use position; authoritative paths stay intact. var id := StringName(guest.target_facility_id) var world: Variant = _service(&"world_service") if world==null or id.is_empty(): return fallback var cells := _cells_from_serialized(world.call(&"get_entity_cells",id)) if cells.is_empty() or cells[0].z!=layer_mode: return fallback var rect := _cell_bounds_rect(cells) var data: Dictionary = _room_presentations.get(id, {}) var slot := (data.get(&"people", []) as Array).find(guest.guest_id) if slot < 0: return fallback var p := _cell_dimensions().x/16.0 var anchors := RoomPresentation.slots(rect, p, _guest_activity(guest)) if slot >= anchors.size(): return Vector2(INF, INF) var at := anchors[slot] if _guest_activity(guest) in [&"standard_room", &"premium_room"]: at.y -= 5*p return at + Vector2(0, 3*p-_cell_dimensions().y*0.5) func _guest_activity(guest: Variant) -> StringName: if String(guest.target_facility_id).is_empty(): return &"" var service: Variant = _service(&"facility_service") var facility: Variant = service.call(&"get_instance",guest.target_facility_id) if service != null else null return StringName(facility.definition_id) if facility != null else &"" func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void: var world_pixel := _cell_dimensions().x / 16.0 var scale := 0.5 if riding else 0.75 var p := maxf(floorf(world_pixel * scale), 1.0) if world_pixel >= 2.0 else world_pixel * scale var track: Dictionary = _actor_tracks.get(key, {}) var moving: bool = not receiving and not _queue_positions.has(key) and track.get(&"cell", Vector3i.ZERO) != track.get(&"from", Vector3i.ZERO) and _animation_time - float(track.get(&"at", 0.0)) < 0.42 var clock := get_node_or_null("/root/GameClock") var paused := clock != null and bool(clock.get("paused")) moving = moving and not paused and not riding var variant := int(absi(key.hash())) % 15 var frame := int(_animation_time * 12.0) + variant var mode: StringName = &"idle" if staff and state == StaffRuntime.State.INCAPACITATED: mode = &"collapsed" elif receiving and activity in [&"standard_room", &"premium_room"]: mode = &"standard_room_sleep" if activity == &"standard_room" else &"sleep" elif moving: mode = &"carry_walk" if carrying else &"walk" elif carrying: mode = &"carry" elif staff and state == StaffRuntime.State.WORKING and not riding: mode = activity if activity in [&"clean", &"idle", &"work"] else &"work" elif receiving and activity in [&"cafeteria", &"tavern"]: mode = &"eat" elif _queue_positions.has(key) or (not staff and state == GuestRuntime.State.QUEUING): mode = &"wait" var at := (center + Vector2(-8 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 17 * p)).round() draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2)) People.draw(self, at, p, mode, frame, variant, staff, float(track.get(&"direction", 1.0)) < 0.0) func _draw_excavation_jobs() -> void: var excavation: Variant = _service(&"excavation_service") if excavation == null or not excavation.has_method(&"get_jobs"): return var font := ThemeDB.fallback_font for job_value: Variant in excavation.call(&"get_jobs"): if not job_value is Dictionary: continue var job: Dictionary = job_value var state := StringName(job.get(&"state", &"")) if state == &"COMPLETED": continue var position: Variant = job.get(&"position", null) if not position is Vector2i: continue var cell := Vector3i(position.x, position.y, layer_mode) var rect := get_cell_rect(cell).grow(-1.0) if rect.size == Vector2.ZERO: continue var paused := state == &"PAUSED" draw_rect(rect, Color("d69a56", 0.26) if paused else Color("4ca5c7", 0.28)) draw_rect(rect, Color("efbd72") if paused else Color("79d3e7"), false, 2.0) if rect.size.x >= 12.0: var percent := clampi(int(job.get(&"progress", 0)) / 100, 0, 100) draw_string( font, rect.position + Vector2(3.0, rect.size.y - 3.0), "掘 %d%%" % percent, HORIZONTAL_ALIGNMENT_LEFT, -1, clampi(int(rect.size.x * 0.38), 8, 12), Color("e5f8fa") ) func _draw_build_preview() -> void: if build_definition == null or not _hovered_cell is Vector3i: return var preview_cells := _cells_from_serialized(_last_preview_report.get(&"cells", [])) if preview_cells.is_empty(): preview_cells = _fallback_build_cells(_hovered_cell) var valid := bool(_last_preview_report.get(&"can_build", false)) var fill := Color("64d48c", 0.34) if valid else Color("e16262", 0.38) var edge := Color("8ff0ab") if valid else Color("ff8a80") for cell: Vector3i in preview_cells: if cell.z != layer_mode: continue var rect := get_cell_rect(cell).grow(-1.0) draw_rect(rect, fill) draw_rect(rect, edge, false, 2.0) _draw_build_connection(_last_preview_report.get(&"connection", {})) func _draw_excavation_preview() -> void: if not excavation_mode or not _hovered_cell is Vector3i: return var preview_cells := _cells_from_serialized( _last_excavation_preview_report.get(&"cells", []) ) if preview_cells.is_empty(): preview_cells = [Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode)] var valid := bool(_last_excavation_preview_report.get(&"ok", false)) var fill := Color("52bdd0", 0.35) if valid else Color("e16262", 0.38) var edge := Color("8ce6ee") if valid else Color("ff8a80") for cell: Vector3i in preview_cells: if cell.z != layer_mode: continue var rect := get_cell_rect(cell).grow(-1.0) draw_rect(rect, fill) draw_rect(rect, edge, false, 2.0) func _draw_hover() -> void: if not _hovered_cell is Vector3i or build_definition != null or excavation_mode: return var rect := get_cell_rect(_hovered_cell).grow(-1.0) draw_rect(rect, Color("d7ebef", 0.75), false, 1.5) func _draw_map_caption(_map_rect: Rect2) -> void: var font := ThemeDB.fallback_font var caption := "世界樹の宿 / お客様のフロア" if layer_mode==LayerMode.FRONT else "世界樹の宿 / 従業員と物流" draw_rect(Rect2(14,12,244,26),Color("29483d",0.93)) draw_string(font,Vector2(24,30),caption,HORIZONTAL_ALIGNMENT_LEFT,-1,13,Color("f0e2bd")) var status := "表示 %d%% · %s · G マス目%s" % [int(zoom_level*100), "裏層:半透明" if layer_mode == LayerMode.FRONT else "表層:輪郭", " ON" if show_grid else ""] var status_width := font.get_string_size(status,HORIZONTAL_ALIGNMENT_LEFT,-1,12).x + 20.0 draw_rect(Rect2(size.x-status_width-14,12,status_width,26),Color("29483d",0.93)) draw_string(font,Vector2(size.x-status_width-4,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd")) func _draw_center_message(message: String) -> void: var font := ThemeDB.fallback_font draw_string(font, Vector2(0, size.y * 0.5), message, HORIZONTAL_ALIGNMENT_CENTER, size.x, 16, Color("8ca4af")) func _actor_offset(cell: Vector3i, counts: Dictionary) -> Vector2: var count := int(counts.get(cell,0)) counts[cell] = count+1 var p := _cell_dimensions().x/16.0 var slots := [0.0,4.0,-4.0,2.0,-2.0,6.0,-6.0] return Vector2(float(slots[count%slots.size()])*p,-float(count%2)*p) func _facility_color(instance_id: StringName) -> Color: var facility_service: Variant = _service(&"facility_service") if facility_service == null or not facility_service.has_method(&"get_instance"): return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582") var instance: Variant = facility_service.call(&"get_instance", instance_id) if instance == null: return Color("6f8b67") if layer_mode == LayerMode.FRONT else Color("427582") match int(instance.state): 0: return Color("a24b45") 1, 2, 3: return Color("a77d3f") 4: return Color("6b6258") 5: return Color("89914d") _: return Color("6f9a55") if layer_mode == LayerMode.FRONT else Color("67518e") func _fallback_build_cells(origin: Vector3i) -> Array[Vector3i]: var result: Array[Vector3i] = [] if build_definition == null: return result var footprint: Variant = build_definition.get(&"footprint") if build_definition is Object else null if build_definition is Dictionary: footprint = build_definition.get(&"footprint", Vector2i.ONE) if not footprint is Vector2i: footprint = Vector2i.ONE var dimensions: Vector2i = footprint if build_rotation in [90, 270]: dimensions = Vector2i(footprint.y, footprint.x) for y_offset in range(dimensions.y): for x_offset in range(dimensions.x): result.append(origin + Vector3i(x_offset, y_offset, 0)) return result func _fallback_cells_are_available(origin: Vector3i) -> bool: var world: Variant = _service(&"world_service") if world == null: return false for cell: Vector3i in _fallback_build_cells(origin): if world.has_method(&"is_in_bounds") and not bool(world.call(&"is_in_bounds", cell)): return false if world.has_method(&"get_occupant") and not StringName(world.call(&"get_occupant", cell)).is_empty(): return false return true func _set_hovered_cell(value: Variant) -> void: if value == _hovered_cell: return _hovered_cell = value hovered_cell_changed.emit(_hovered_cell) _update_build_preview() _update_excavation_preview() queue_redraw() func _update_build_preview() -> void: _preview_topology_key = _current_preview_topology() _last_preview_report = get_build_preview_report() build_preview_changed.emit(_last_preview_report.duplicate(true)) func _update_excavation_preview() -> void: _last_excavation_preview_report = ( get_excavation_preview_report() if excavation_mode else INVALID_EXCAVATION_PREVIEW.duplicate(true) ) excavation_preview_changed.emit(_last_excavation_preview_report.duplicate(true)) func _on_mouse_exited() -> void: _set_hovered_cell(null) func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: _connection_cache_key.clear() _connection_cache_report.clear() queue_redraw() _update_build_preview() _update_excavation_preview() func _connect_runtime() -> void: if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): return var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published" var callback := Callable(self, "_on_snapshot_published") if not _runtime.is_connected(notification, callback): _runtime.connect(notification, callback) func _disconnect_runtime() -> void: if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): return var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published" var callback := Callable(self, "_on_snapshot_published") if _runtime.is_connected(notification, callback): _runtime.disconnect(notification, callback) func _service(property_name: StringName) -> Variant: if _runtime == null or not _runtime is Object: return null if _drawing and _draw_cache.has(property_name): return _draw_cache[property_name] for property: Dictionary in _runtime.get_property_list(): if StringName(property.get(&"name", &"")) == property_name: var value = _runtime.get(property_name) if _drawing: _draw_cache[property_name] = value return value return null func _world_bounds() -> Dictionary: if _drawing and _draw_cache.has(&"bounds"): return _draw_cache[&"bounds"] var world: Variant = _service(&"world_service") if world == null or not world.has_method(&"get_bounds"): return {} var result: Variant = world.call(&"get_bounds") if not result is Dictionary: return {} for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]: if not result.has(key) and not result.has(String(key)): return {} var result_value = { &"min_x": int(result.get(&"min_x", result.get("min_x", 0))), &"max_x": int(result.get(&"max_x", result.get("max_x", -1))), &"min_y": int(result.get(&"min_y", result.get("min_y", 0))), &"max_y": int(result.get(&"max_y", result.get("max_y", -1))), } if _drawing: _draw_cache[&"bounds"] = result_value return result_value func _cells_from_serialized(value: Variant) -> Array[Vector3i]: var result: Array[Vector3i] = [] if not value is Array: return result for raw_cell: Variant in value: if raw_cell is Vector3i: result.append(raw_cell) elif raw_cell is Array and raw_cell.size() == 3: result.append(Vector3i(int(raw_cell[0]), int(raw_cell[1]), int(raw_cell[2]))) return result func _serialize_cells(cells: Array[Vector3i]) -> Array[Array]: var result: Array[Array] = [] for cell: Vector3i in cells: result.append([cell.x, cell.y, cell.z]) return result func _normalized_rotation(value: int) -> int: return posmod(value, 360) / 90 * 90 func _rescue_treatment_position(patient_id: StringName, fallback: Vector2) -> Vector2: var facility_id: StringName = _treated_patients[patient_id] var world: Variant = _service(&"world_service") var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] if cells.is_empty(): return fallback var data: Dictionary = _room_presentations.get(facility_id, {}) var slot := (data.get(&"people", []) as Array).find(patient_id) var p := _cell_dimensions().x/16.0 var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), p, &"infirmary") if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF) return anchors[slot]+Vector2(0,-2*p-_cell_dimensions().y*0.5) func _resting_staff_position(staff_id: StringName, fallback: Vector2) -> Vector2: var facility_id: StringName = _resting_staff[staff_id] var world: Variant = _service(&"world_service") var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] if cells.is_empty(): return fallback var data: Dictionary = _room_presentations.get(facility_id, {}) var slot := (data.get(&"people", []) as Array).find(staff_id) var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), _cell_dimensions().x/16.0, &"staff_waiting_room") if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF) return anchors[slot]+Vector2(0,-_cell_dimensions().y*0.5) func _can_drop_data(_position: Vector2, data: Variant) -> bool: return data is Dictionary and data.get(&"kind", &"") == &"staff_assignment" and data.get(&"staff_id", &"") is StringName func _drop_data(position: Vector2, data: Variant) -> void: if _can_drop_data(position, data): staff_assignment_dropped.emit(data, facility_at_canvas(position)) func _current_preview_topology() -> Array: var world: Variant = _service(&"world_service") var path: Variant = _service(&"path_service") return [world.revision if world != null else -1, path.graph_revision if path != null else -1, path.traffic_revision if path != null else -1] ## Read-only connection preview. Shares ordinary facility access candidates and ## accessible graph edges, but does not issue live path queries or change routing. ## The displayed path proves connectivity; it is not a cheapest-route promise. func _build_connection_report(cells: Array[Vector3i], origin: Vector3i) -> Dictionary: var path: Variant = _service(&"path_service") if path == null or _runtime == null or cells.is_empty(): return {&"status": &"UNAVAILABLE", &"reachable": false, &"path": []} var entrance: Variant = _runtime.call(&"_operations_entrance_cell") var key: Array = [origin, cells, _current_preview_topology(), entrance] if key == _connection_cache_key: return _connection_cache_report.duplicate(true) var result := {&"status": &"UNAVAILABLE", &"reachable": false, &"entrance": entrance, &"origin": origin, &"path": [], &"access_points": []} if entrance is Vector3i: var future := FacilityInstance.new() future.origin = origin future.occupied_cells.assign(cells) var candidates: Array = _runtime.call(&"_facility_access_candidates", future) result[&"access_points"] = candidates var targets: Dictionary = {} for cell: Vector3i in candidates: targets[cell] = true var queue: Array[Vector3i] = [entrance] var parents: Dictionary = {} parents[entrance] = entrance var cursor := 0 var target: Variant = null while cursor < queue.size(): var current := queue[cursor] cursor += 1 if targets.has(current): target = current break for edge: Dictionary in path.get_neighbors(current): var next: Vector3i = edge[&"to"] if parents.has(next) or path.get_edge_report(current, next).is_empty(): continue parents[next] = current queue.append(next) var route: Array[Vector3i] = [] if target is Vector3i: var current: Vector3i = target while current != entrance: route.push_front(current) current = parents[current] route.push_front(entrance) result[&"reachable"] = not route.is_empty() result[&"status"] = &"CONNECTED" if result[&"reachable"] else &"DISCONNECTED" result[&"path"] = route _connection_cache_key = key.duplicate(true) _connection_cache_report = result.duplicate(true) return result func _draw_build_connection(report: Dictionary) -> void: if report.is_empty() or not _hovered_cell is Vector3i: return var status: StringName = report.get(&"status", &"UNAVAILABLE") var color := Color("8ff0ab") if status == &"CONNECTED" else Color("ffd080") var target := cell_to_canvas(_hovered_cell) var route: Array = report.get(&"path", []) if status == &"CONNECTED": for i in range(1, route.size()): var from: Vector3i = route[i-1] var to: Vector3i = route[i] if from.z == layer_mode and to.z == layer_mode: draw_line(cell_to_canvas(from), cell_to_canvas(to), color, 2.0) if not route.is_empty() and (route.back() as Vector3i).z == layer_mode: draw_line(cell_to_canvas(route.back()), target, color, 2.0) elif report.get(&"entrance") is Vector3i: # Dashed straight guide is a warning, never a traversable-path claim. draw_dashed_line(cell_to_canvas(report[&"entrance"]), target, color, 2.0, 8.0) draw_line(target-Vector2(6,6),target+Vector2(6,6),color,2.0) draw_line(target+Vector2(-6,6),target+Vector2(6,-6),color,2.0) var message := "入口への経路接続済" if status == &"CONNECTED" else "入口への経路未接続" if status == &"DISCONNECTED" else "入口・経路を確認できません" draw_string(ThemeDB.fallback_font, target+Vector2(8,-12), message, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, color)