Files
TheTree_mock/scenes/main/world_preview.gd
T

1103 lines
43 KiB
GDScript

class_name WorldPreview
extends Control
signal facility_selected(instance_id: StringName)
signal construction_selected(job_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 PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
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):
layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode
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 _animation_time := 0.0
var _animation_frame := -1
var _actor_tracks: Dictionary = {}
var _elevator_tracks: Dictionary = {}
var _boarded_actor_ids: Dictionary = {}
var _dragging := false
var _runtime: Variant = null
var _hovered_cell: Variant = null
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()
queue_redraw()
func set_runtime(value: Variant) -> void:
_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"):
return report.call(&"to_dictionary") as Dictionary
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:
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
return Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size)
func _cell_dimensions() -> Vector2:
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)
return (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6))
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 _clamp_pan() -> void:
var extent := get_map_rect().size * 0.5 + size * 0.3
pan_offset = pan_offset.clamp(-extent, extent)
func _process(delta: float) -> void:
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 event.button_index == MOUSE_BUTTON_MIDDLE:
_dragging = event.pressed
mouse_default_cursor_shape = Control.CURSOR_DRAG if _dragging else Control.CURSOR_ARROW
accept_event()
return
if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
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:
pan_offset += event.relative
_clamp_pan()
queue_redraw()
accept_event()
return
if event is InputEventMouseMotion:
_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 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:
_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)
_draw_world_entity_icons()
_draw_elevators()
_draw_construction_labels()
_draw_facility_labels()
_draw_queue_badges()
_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)
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 construction.call(&"get_facilities"):
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)
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)
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_queue_badges() -> void:
var service: Variant = _service(&"facility_service")
if service==null or not service.has_method(&"get_instances"):
return
for instance: Variant in service.call(&"get_instances"):
if instance==null or instance.origin.z!=layer_mode or instance.queue_entries.is_empty():
continue
var count: int = instance.queue_entries.size()
var rect := get_cell_rect(instance.origin)
var at := Vector2(rect.position.x+3,rect.position.y+3)
draw_rect(Rect2(at,Vector2(30,15)),Color("76593f"))
draw_rect(Rect2(at+Vector2.ONE,Vector2(28,13)),Color("bb974e"))
draw_string(ThemeDB.fallback_font,at+Vector2(3,11),"待%d" % count,HORIZONTAL_ALIGNMENT_LEFT,-1,10,Color("fff2ce"))
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.call(&"get_jobs"):
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 construction.call(&"get_facilities"):
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"] 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.call(&"get_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 visible_count := mini(passengers.size(), maxi(width*2-1,1))
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+5*p+float(index)*(cabin.size.x-10*p)/maxf(float(visible_count-1),1.0)
if visible_count == 1:
x = cabin.get_center().x
var center := Vector2(x,cabin.end.y-_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 waiting := int(shaft.get(&"waiting_count",0))
if waiting > 0:
var badge := Vector2(cabin.end.x+2*p,cabin.position.y+2*p)
draw_rect(Rect2(badge,Vector2(20,12)*p),Color("76593f"))
draw_string(ThemeDB.fallback_font,badge+Vector2(2,9)*p,"待%d" % waiting,HORIZONTAL_ALIGNMENT_LEFT,-1,clampi(int(7*p),8,13),Color("f6e8bc"))
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):
_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 receiving:
position = _service_use_position(guest,position,service_slots)
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):
_actor_tracks.erase(key)
continue
var position := _tracked_actor_position(key,staff.current_cell)
if staff.current_cell.z==layer_mode:
_draw_pixel_actor(position+_actor_offset(staff.current_cell,counts),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),staff.role_id)
for key: Variant in _actor_tracks.keys():
if not alive.has(key):
_actor_tracks.erase(key)
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 slot := int(slots.get(id,0))
slots[id] = slot+1
var p := _cell_dimensions().x/16.0
var x := rect.position.x+minf(9.0+float(slot%4)*14.0,rect.size.x/p-5.0)*p
return Vector2(x,rect.end.y-_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 texture := visual_theme.pixel_dungeon_tiles if visual_theme != null else null
if texture==null:
return
var world_pixel := _cell_dimensions().x/16.0
var p := maxf(floorf(world_pixel*0.5),1.0) if world_pixel>=1.0 else world_pixel
var track: Dictionary = _actor_tracks.get(key,{})
var moving: bool = 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()))%5
var tile_id := (96 if staff else 84)+variant
var frame := int(_animation_time*9.0+variant)%4
var step := (1.0 if frame==0 else (-1.0 if frame==2 else 0.0)) if moving else 0.0
var at := (center+Vector2(-8*p, _cell_dimensions().y*0.5-3*world_pixel-16*p)).round()
var source := Vector2((tile_id%12)*16,(tile_id/12)*16)
# Retain the same character; split only the feet into a four-phase walk.
# Neighbouring atlas tiles are other people, never animation frames.
var bob := -p if moving and frame%2==0 else 0.0
draw_rect(Rect2(at+Vector2(4,14)*p,Vector2(8,1)*p),Color("3a453c",0.35))
var flip := float(track.get(&"direction",1.0))<0.0
var torso := Rect2(at+Vector2(0,bob),Vector2(16,15)*p)
if flip:
torso.position.x += torso.size.x
torso.size.x = -torso.size.x
draw_texture_rect_region(texture,torso,Rect2(source,Vector2(16,15)))
for leg in range(2):
var leg_x := float(leg)*8*p
var stride := step*p*(1.0 if leg==0 else -1.0)
var foot := Rect2(at+Vector2(leg_x,14*p+stride+bob),Vector2(8,2)*p)
if flip:
foot.position.x = at.x+float(2-leg)*8*p
foot.size.x = -foot.size.x
draw_texture_rect_region(texture,foot,Rect2(source+Vector2(leg*8,14),Vector2(8,2)))
if staff:
draw_rect(Rect2(at+Vector2(7,8)*p,Vector2(2,3)*p),Color("81c6ba"))
if carrying:
PixelArt.tile(self,texture,89,Rect2(at+Vector2(9,6)*p,Vector2(9,9)*p))
var working := staff and state==StaffRuntime.State.WORKING and not riding
var resting := receiving and activity==&"standard_room"
if resting:
var lift := float(int(_animation_time*2)%3) if not paused else 0.0
draw_string(ThemeDB.fallback_font,at+Vector2(11,-1-lift)*p,"z",HORIZONTAL_ALIGNMENT_LEFT,-1,maxi(int(6*p),7),Color("f7efd6"))
if staff and activity==&"cleaner" and (moving or working):
var sweep := step*p if working or moving else 0.0
draw_rect(Rect2(at+Vector2(12*p+sweep,7*p),Vector2(p,8*p)),Color("b7975c"))
draw_rect(Rect2(at+Vector2(10*p+sweep,14*p),Vector2(5*p,2*p)),Color("cdb375"))
if receiving and activity in [&"cafeteria",&"tavern"]:
var lift := p if not paused and int(_animation_time*3)%2==0 else 0.0
draw_rect(Rect2(at+Vector2(11*p,8*p-lift),Vector2(3*p,2*p)),Color("e6d7b5"))
var waiting := not staff and state==GuestRuntime.State.QUEUING and not riding
if (receiving and not resting) or working or waiting:
var badge := Rect2(at+Vector2(10,-4)*p,Vector2(6,5)*p)
draw_rect(badge,Color("f2e9cb"))
var mark := Color("c39150") if waiting else Color("61958a")
if waiting:
draw_rect(Rect2(badge.position+Vector2(2,1)*p,Vector2(1,2)*p),mark)
draw_rect(Rect2(badge.position+Vector2(2,3)*p,Vector2(2,1)*p),mark)
else:
var offset := 1.0 if not paused and int(_animation_time*3)%2==0 else 0.0
draw_rect(Rect2(badge.position+Vector2(1+offset,2)*p,Vector2(3,1)*p),mark)
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)
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%% · G マス目%s" % [int(zoom_level*100), " ON" if show_grid else ""]
draw_rect(Rect2(size.x-214,12,200,26),Color("29483d",0.93))
draw_string(font,Vector2(size.x-204,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:
_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:
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 callback := Callable(self, "_on_snapshot_published")
if not _runtime.is_connected(&"snapshot_published", callback):
_runtime.connect(&"snapshot_published", callback)
func _disconnect_runtime() -> void:
if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"):
return
var callback := Callable(self, "_on_snapshot_published")
if _runtime.is_connected(&"snapshot_published", callback):
_runtime.disconnect(&"snapshot_published", callback)
func _service(property_name: StringName) -> Variant:
if _runtime == null or not _runtime is Object:
return null
for property: Dictionary in _runtime.get_property_list():
if StringName(property.get(&"name", &"")) == property_name:
return _runtime.get(property_name)
return null
func _world_bounds() -> Dictionary:
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 {}
return {
&"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))),
}
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