2026-08-01 22:55:39 -04:00
|
|
|
class_name NetworkSurfaceDrawingService
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
const BrushHighlightShader: Shader = preload(
|
|
|
|
|
"res://drawing/surface_drawing_highlight.gdshader"
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
|
2026-08-01 22:55:39 -04:00
|
|
|
|
|
|
|
|
signal hud_state_changed(
|
|
|
|
|
is_active: bool,
|
|
|
|
|
mode_name: String,
|
|
|
|
|
color_name: String,
|
|
|
|
|
color_value: Color,
|
|
|
|
|
brush_size: int,
|
2026-08-02 00:45:07 -04:00
|
|
|
grid_size: int,
|
2026-08-01 22:55:39 -04:00
|
|
|
status: String,
|
|
|
|
|
)
|
|
|
|
|
signal session_artwork_changed(canvas_count: int, painted_cell_count: int)
|
|
|
|
|
|
|
|
|
|
const SOLID_SURFACE_MASK: int = 1
|
|
|
|
|
const MAX_DRAW_DISTANCE: float = 16.0
|
|
|
|
|
const SURFACE_VALIDATION_DEPTH: float = 0.5
|
|
|
|
|
const MIN_SURFACE_NORMAL_DOT: float = 0.35
|
|
|
|
|
const EDIT_INTERVAL_MSEC: int = 45
|
|
|
|
|
const REQUEST_WINDOW_MSEC: int = 1000
|
|
|
|
|
const MAX_REQUESTS_PER_WINDOW: int = 32
|
|
|
|
|
const MAX_RECENT_REQUEST_IDS: int = 64
|
|
|
|
|
const GRID_PREVIEW_SURFACE_OFFSET: float = 0.03
|
|
|
|
|
const POINTER_EDGE_MARGIN: float = 2.0
|
2026-08-05 16:25:16 -04:00
|
|
|
const INVALID_POINTER_SCREEN_POSITION := Vector2(-1.0, -1.0)
|
2026-08-01 22:55:39 -04:00
|
|
|
|
2026-08-02 09:20:44 -04:00
|
|
|
enum GuideAction {
|
|
|
|
|
NONE,
|
|
|
|
|
HIDE,
|
|
|
|
|
RESTORE,
|
|
|
|
|
FINALIZE,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
var _session: NetworkSession
|
|
|
|
|
var _spawn_service: PlayerSpawnService
|
|
|
|
|
var _relationships: PlayerRelationshipStore
|
|
|
|
|
var _local_player: Player
|
2026-08-02 00:45:07 -04:00
|
|
|
var _bag: PlayerBag
|
2026-08-13 15:24:23 -04:00
|
|
|
var _hotbar: PlayerHotbar
|
2026-08-02 00:45:07 -04:00
|
|
|
var _art_unlocks: PlayerArtUnlocks
|
2026-08-01 22:55:39 -04:00
|
|
|
var _drawing_root: Node3D
|
|
|
|
|
var _canvas_states: Dictionary[String, Dictionary] = {}
|
|
|
|
|
var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {}
|
|
|
|
|
var _selected_canvas_id: String = ""
|
|
|
|
|
var _brush_preview: MultiMeshInstance3D
|
|
|
|
|
var _brush_preview_multimesh: MultiMesh
|
|
|
|
|
var _brush_preview_material: ShaderMaterial
|
|
|
|
|
var _placement_preview: MeshInstance3D
|
|
|
|
|
var _placement_preview_material: StandardMaterial3D
|
|
|
|
|
var _active: bool = false
|
|
|
|
|
var _placing_grid: bool = false
|
|
|
|
|
var _brush_size: int = 1
|
2026-08-02 00:45:07 -04:00
|
|
|
var _grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE
|
2026-08-01 22:55:39 -04:00
|
|
|
var _color_ids: Array[StringName] = []
|
|
|
|
|
var _color_index: int = 0
|
|
|
|
|
var _painting: bool = false
|
|
|
|
|
var _erasing: bool = false
|
2026-08-02 09:20:44 -04:00
|
|
|
var _eraser_mode: bool = false
|
|
|
|
|
var _armed_guide_action: int = GuideAction.NONE
|
|
|
|
|
var _armed_return_placement_mode: bool = false
|
|
|
|
|
var _armed_return_eraser_mode: bool = false
|
2026-08-01 22:55:39 -04:00
|
|
|
var _camera_look_active: bool = false
|
|
|
|
|
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
|
|
|
|
|
var _last_edited_cell := Vector3i(-1, -1, -1)
|
|
|
|
|
var _active_stroke_id: String = ""
|
|
|
|
|
var _last_local_stroke_id: String = ""
|
|
|
|
|
var _last_edit_msec: int = 0
|
|
|
|
|
var _aim_hit: Dictionary = {}
|
|
|
|
|
var _pointer_screen_position: Vector2 = Vector2.ZERO
|
|
|
|
|
var _request_sequence: int = 0
|
|
|
|
|
var _canvas_sequence: int = 0
|
|
|
|
|
var _peer_request_times: Dictionary[int, PackedInt64Array] = {}
|
|
|
|
|
var _peer_request_ids: Dictionary[int, PackedStringArray] = {}
|
|
|
|
|
var _stroke_history_by_peer: Dictionary[int, Dictionary] = {}
|
|
|
|
|
var _cell_last_stroke: Dictionary[String, String] = {}
|
2026-08-02 00:45:07 -04:00
|
|
|
var _peer_art_entitlements: Dictionary[int, Dictionary] = {}
|
2026-08-01 22:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func setup(
|
|
|
|
|
session: NetworkSession,
|
|
|
|
|
spawn_service: PlayerSpawnService,
|
|
|
|
|
relationships: PlayerRelationshipStore,
|
|
|
|
|
local_player: Player,
|
|
|
|
|
drawing_root: Node3D,
|
2026-08-02 00:45:07 -04:00
|
|
|
bag: PlayerBag,
|
2026-08-13 15:24:23 -04:00
|
|
|
hotbar: PlayerHotbar,
|
2026-08-02 00:45:07 -04:00
|
|
|
art_unlocks: PlayerArtUnlocks,
|
2026-08-01 22:55:39 -04:00
|
|
|
) -> void:
|
|
|
|
|
_session = session
|
|
|
|
|
_spawn_service = spawn_service
|
|
|
|
|
_relationships = relationships
|
|
|
|
|
_local_player = local_player
|
|
|
|
|
_drawing_root = drawing_root
|
2026-08-02 00:45:07 -04:00
|
|
|
_bag = bag
|
2026-08-13 15:24:23 -04:00
|
|
|
_hotbar = hotbar
|
2026-08-02 00:45:07 -04:00
|
|
|
_art_unlocks = art_unlocks
|
|
|
|
|
_refresh_local_unlocks()
|
2026-08-01 22:55:39 -04:00
|
|
|
if _session != null:
|
|
|
|
|
_session.state_changed.connect(_on_session_state_changed)
|
|
|
|
|
_session.peer_removed.connect(_on_peer_removed)
|
|
|
|
|
if _relationships != null:
|
|
|
|
|
_relationships.relationship_changed.connect(
|
|
|
|
|
_on_relationship_changed
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
if _bag != null and not _bag.contents_changed.is_connected(
|
|
|
|
|
_on_local_art_entitlement_changed
|
|
|
|
|
):
|
|
|
|
|
_bag.contents_changed.connect(_on_local_art_entitlement_changed)
|
|
|
|
|
if _art_unlocks != null and not _art_unlocks.unlocks_changed.is_connected(
|
|
|
|
|
_on_local_art_unlocks_changed
|
|
|
|
|
):
|
|
|
|
|
_art_unlocks.unlocks_changed.connect(_on_local_art_unlocks_changed)
|
2026-08-01 22:55:39 -04:00
|
|
|
_create_brush_preview()
|
|
|
|
|
_create_placement_preview()
|
|
|
|
|
set_process(true)
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 16:25:16 -04:00
|
|
|
func handle_input(
|
|
|
|
|
event: InputEvent,
|
|
|
|
|
can_open: bool,
|
|
|
|
|
pointer_screen_position: Vector2 = INVALID_POINTER_SCREEN_POSITION,
|
|
|
|
|
) -> bool:
|
2026-08-01 22:55:39 -04:00
|
|
|
if not _active:
|
|
|
|
|
return false
|
|
|
|
|
if not can_open:
|
|
|
|
|
deactivate()
|
|
|
|
|
return false
|
|
|
|
|
if event.is_action_pressed("ui_cancel"):
|
2026-08-02 09:20:44 -04:00
|
|
|
if _armed_guide_action != GuideAction.NONE:
|
|
|
|
|
_cancel_armed_guide_action()
|
|
|
|
|
return true
|
2026-08-01 22:55:39 -04:00
|
|
|
if _placing_grid:
|
|
|
|
|
_set_placement_mode(false)
|
|
|
|
|
return true
|
2026-08-13 15:24:23 -04:00
|
|
|
return false
|
2026-08-01 22:55:39 -04:00
|
|
|
if event is InputEventKey:
|
|
|
|
|
var key_event := event as InputEventKey
|
|
|
|
|
if not key_event.pressed or key_event.echo:
|
|
|
|
|
return false
|
|
|
|
|
if key_event.ctrl_pressed and key_event.physical_keycode == KEY_Z:
|
|
|
|
|
request_undo_last_stroke()
|
|
|
|
|
return true
|
|
|
|
|
match key_event.physical_keycode:
|
|
|
|
|
KEY_Q:
|
|
|
|
|
if not _placing_grid:
|
|
|
|
|
_cycle_color(-1)
|
|
|
|
|
return true
|
|
|
|
|
KEY_E:
|
|
|
|
|
if not _placing_grid:
|
|
|
|
|
_cycle_color(1)
|
|
|
|
|
return true
|
|
|
|
|
KEY_R:
|
|
|
|
|
_set_placement_mode(not _placing_grid)
|
|
|
|
|
return true
|
|
|
|
|
if event is InputEventMouseButton:
|
|
|
|
|
var mouse_event := event as InputEventMouseButton
|
2026-08-13 15:24:23 -04:00
|
|
|
# Ordinary wheel input always belongs to Hotbar selection. Brush size is
|
|
|
|
|
# selected explicitly in the Art Kit toolbar.
|
|
|
|
|
if mouse_event.button_index in [
|
|
|
|
|
MOUSE_BUTTON_WHEEL_UP,
|
|
|
|
|
MOUSE_BUTTON_WHEEL_DOWN,
|
|
|
|
|
]:
|
|
|
|
|
return false
|
2026-08-05 16:25:16 -04:00
|
|
|
_set_pointer_from_input(
|
|
|
|
|
mouse_event.position,
|
|
|
|
|
pointer_screen_position,
|
|
|
|
|
)
|
|
|
|
|
_update_aim()
|
2026-08-01 22:55:39 -04:00
|
|
|
match mouse_event.button_index:
|
|
|
|
|
MOUSE_BUTTON_LEFT:
|
|
|
|
|
if _placing_grid:
|
|
|
|
|
if mouse_event.pressed:
|
2026-08-02 09:20:44 -04:00
|
|
|
if _armed_guide_action != GuideAction.NONE:
|
|
|
|
|
_execute_armed_guide_action()
|
|
|
|
|
elif mouse_event.shift_pressed and mouse_event.ctrl_pressed:
|
2026-08-01 22:55:39 -04:00
|
|
|
_finalize_selected_guide()
|
|
|
|
|
elif mouse_event.shift_pressed:
|
|
|
|
|
_remove_selected_guide()
|
|
|
|
|
else:
|
|
|
|
|
_request_canvas_at_aim()
|
|
|
|
|
return true
|
|
|
|
|
if mouse_event.pressed:
|
|
|
|
|
_begin_stroke()
|
2026-08-02 09:20:44 -04:00
|
|
|
_erasing = (
|
|
|
|
|
mouse_event.pressed
|
|
|
|
|
and (mouse_event.shift_pressed or _eraser_mode)
|
|
|
|
|
)
|
|
|
|
|
_painting = mouse_event.pressed and not _erasing
|
2026-08-01 22:55:39 -04:00
|
|
|
if _painting or _erasing:
|
|
|
|
|
_submit_current_brush(_erasing, true)
|
|
|
|
|
else:
|
|
|
|
|
_finish_stroke()
|
|
|
|
|
return true
|
|
|
|
|
MOUSE_BUTTON_RIGHT:
|
|
|
|
|
_camera_look_active = mouse_event.pressed
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
return false
|
|
|
|
|
if event is InputEventMouseMotion:
|
|
|
|
|
var motion_event := event as InputEventMouseMotion
|
|
|
|
|
if _camera_look_active:
|
|
|
|
|
return false
|
2026-08-05 16:25:16 -04:00
|
|
|
_set_pointer_from_input(
|
|
|
|
|
motion_event.position,
|
|
|
|
|
pointer_screen_position,
|
2026-08-01 22:55:39 -04:00
|
|
|
)
|
|
|
|
|
_update_aim()
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 16:25:16 -04:00
|
|
|
func activate(
|
|
|
|
|
pointer_screen_position: Vector2 = INVALID_POINTER_SCREEN_POSITION,
|
|
|
|
|
) -> void:
|
2026-08-02 00:45:07 -04:00
|
|
|
if _active or not can_activate():
|
2026-08-01 22:55:39 -04:00
|
|
|
return
|
|
|
|
|
_active = true
|
2026-08-13 15:24:23 -04:00
|
|
|
_placing_grid = false
|
2026-08-02 09:20:44 -04:00
|
|
|
_eraser_mode = false
|
|
|
|
|
_clear_armed_guide_action(false)
|
2026-08-02 00:45:07 -04:00
|
|
|
_refresh_local_unlocks()
|
|
|
|
|
_rebuild_placement_preview()
|
2026-08-01 22:55:39 -04:00
|
|
|
_reset_stroke()
|
2026-08-05 16:25:16 -04:00
|
|
|
var initial_pointer_position: Vector2 = pointer_screen_position
|
|
|
|
|
if not _is_valid_pointer_screen_position(initial_pointer_position):
|
|
|
|
|
initial_pointer_position = get_viewport().get_mouse_position()
|
|
|
|
|
_pointer_screen_position = _clamped_pointer_position(
|
|
|
|
|
initial_pointer_position
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
_prior_mouse_mode = Input.mouse_mode
|
2026-08-02 00:45:07 -04:00
|
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
2026-08-01 22:55:39 -04:00
|
|
|
_update_aim()
|
|
|
|
|
_refresh_stencil_visibility()
|
2026-08-13 15:24:23 -04:00
|
|
|
_emit_hud_state("marker selected")
|
2026-08-01 22:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func deactivate() -> void:
|
|
|
|
|
if not _active:
|
|
|
|
|
return
|
|
|
|
|
_active = false
|
|
|
|
|
_placing_grid = false
|
2026-08-02 09:20:44 -04:00
|
|
|
_eraser_mode = false
|
|
|
|
|
_clear_armed_guide_action(false)
|
2026-08-01 22:55:39 -04:00
|
|
|
_camera_look_active = false
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_selected_canvas_id = ""
|
|
|
|
|
_aim_hit.clear()
|
|
|
|
|
_hide_previews()
|
|
|
|
|
_refresh_stencil_visibility()
|
|
|
|
|
Input.mouse_mode = _prior_mouse_mode
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_active() -> bool:
|
|
|
|
|
return _active
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_placement_mode() -> bool:
|
|
|
|
|
return _placing_grid
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func can_activate() -> bool:
|
2026-08-13 15:24:23 -04:00
|
|
|
return (
|
|
|
|
|
_drawing_available()
|
|
|
|
|
and _owns_art_kit()
|
|
|
|
|
and _art_kit_is_selected()
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_placement_mode(enabled: bool) -> void:
|
|
|
|
|
_set_placement_mode(enabled)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_color_id(color_id: StringName) -> bool:
|
|
|
|
|
var color_index: int = _color_ids.find(color_id)
|
|
|
|
|
if color_index < 0:
|
|
|
|
|
return false
|
2026-08-02 09:20:44 -04:00
|
|
|
_clear_armed_guide_action(true)
|
|
|
|
|
_eraser_mode = false
|
2026-08-02 00:45:07 -04:00
|
|
|
_color_index = color_index
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 09:20:44 -04:00
|
|
|
func set_eraser_mode(enabled: bool) -> void:
|
|
|
|
|
if not _active:
|
|
|
|
|
return
|
|
|
|
|
_clear_armed_guide_action(true)
|
|
|
|
|
_eraser_mode = enabled
|
|
|
|
|
if _eraser_mode and _placing_grid:
|
|
|
|
|
_placing_grid = false
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_selected_canvas_id = ""
|
|
|
|
|
_update_aim()
|
|
|
|
|
_refresh_stencil_visibility()
|
|
|
|
|
_emit_hud_state("eraser selected" if _eraser_mode else "marker selected")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_eraser_mode() -> bool:
|
|
|
|
|
return _eraser_mode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func arm_guide_action(action: int) -> bool:
|
|
|
|
|
if (
|
|
|
|
|
not _active
|
|
|
|
|
or action not in [GuideAction.HIDE, GuideAction.RESTORE, GuideAction.FINALIZE]
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
if _armed_guide_action == action:
|
|
|
|
|
_cancel_armed_guide_action()
|
|
|
|
|
return false
|
|
|
|
|
if _armed_guide_action == GuideAction.NONE:
|
|
|
|
|
_armed_return_placement_mode = _placing_grid
|
|
|
|
|
_armed_return_eraser_mode = _eraser_mode
|
|
|
|
|
_armed_guide_action = action
|
|
|
|
|
_placing_grid = true
|
|
|
|
|
_eraser_mode = false
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_selected_canvas_id = ""
|
|
|
|
|
_update_aim()
|
|
|
|
|
_refresh_stencil_visibility()
|
2026-08-13 14:53:39 -04:00
|
|
|
_emit_hud_state(_guide_action_status(action))
|
2026-08-02 09:20:44 -04:00
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_armed_guide_action() -> int:
|
|
|
|
|
return _armed_guide_action
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func cancel_armed_guide_action() -> void:
|
|
|
|
|
_cancel_armed_guide_action()
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func set_brush_size(value: int) -> bool:
|
|
|
|
|
if _art_unlocks == null or not _art_unlocks.is_brush_size_unlocked(value):
|
|
|
|
|
return false
|
|
|
|
|
_set_brush_size(value)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_grid_size(value: int) -> bool:
|
|
|
|
|
if _art_unlocks == null or not _art_unlocks.is_grid_size_unlocked(value):
|
|
|
|
|
return false
|
|
|
|
|
if _grid_size == value:
|
|
|
|
|
return true
|
|
|
|
|
_grid_size = value
|
|
|
|
|
_rebuild_placement_preview()
|
|
|
|
|
_update_previews()
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_brush_size() -> int:
|
|
|
|
|
return _brush_size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_grid_size() -> int:
|
|
|
|
|
return _grid_size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_color_id() -> StringName:
|
|
|
|
|
return _current_color_id()
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func get_pointer_screen_position() -> Vector2:
|
|
|
|
|
return _pointer_screen_position
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_unlocked_color_ids(unlocked_ids: Array[StringName]) -> void:
|
|
|
|
|
_color_ids = SurfaceDrawingPalette.filter_unlocked_ids(unlocked_ids)
|
|
|
|
|
_color_index = clampi(_color_index, 0, _color_ids.size() - 1)
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _refresh_local_unlocks() -> void:
|
|
|
|
|
if _art_unlocks == null:
|
|
|
|
|
_color_ids = [SurfaceDrawingPalette.DEFAULT_COLOR_ID]
|
|
|
|
|
return
|
|
|
|
|
var previous_color: StringName = _current_color_id()
|
|
|
|
|
_color_ids = _art_unlocks.get_unlocked_color_ids()
|
|
|
|
|
var previous_index: int = _color_ids.find(previous_color)
|
|
|
|
|
_color_index = previous_index if previous_index >= 0 else 0
|
|
|
|
|
if not _art_unlocks.is_brush_size_unlocked(_brush_size):
|
|
|
|
|
_brush_size = PlayerArtUnlocks.BASE_BRUSH_SIZE
|
|
|
|
|
if not _art_unlocks.is_grid_size_unlocked(_grid_size):
|
|
|
|
|
_grid_size = PlayerArtUnlocks.BASE_GRID_SIZE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _owns_art_kit() -> bool:
|
|
|
|
|
return _bag != null and _bag.owns_item(ArtShopStockType.ART_KIT_ITEM_ID)
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 15:24:23 -04:00
|
|
|
func _art_kit_is_selected() -> bool:
|
|
|
|
|
return (
|
|
|
|
|
_hotbar != null
|
|
|
|
|
and _hotbar.get_selected_assignment_kind()
|
|
|
|
|
== PlayerHotbar.AssignmentKind.ITEM
|
|
|
|
|
and _hotbar.get_selected_item_id() == ArtShopStockType.ART_KIT_ITEM_ID
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _local_entitlement() -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"has_kit": _owns_art_kit(),
|
|
|
|
|
"unlock_mask": (
|
|
|
|
|
_art_unlocks.get_unlock_mask() if _art_unlocks != null else 0
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _publish_local_entitlement() -> void:
|
|
|
|
|
if _session == null or not _session.is_gameplay_session_active():
|
|
|
|
|
return
|
|
|
|
|
var entitlement: Dictionary = _local_entitlement()
|
|
|
|
|
if _session.is_host():
|
|
|
|
|
_peer_art_entitlements[_session.get_local_peer_id()] = entitlement
|
|
|
|
|
else:
|
|
|
|
|
submit_art_entitlement.rpc_id(
|
|
|
|
|
1,
|
|
|
|
|
_session.get_session_id(),
|
|
|
|
|
bool(entitlement["has_kit"]),
|
|
|
|
|
int(entitlement["unlock_mask"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func submit_art_entitlement(
|
|
|
|
|
session_id: String,
|
|
|
|
|
has_kit: bool,
|
|
|
|
|
unlock_mask: int,
|
|
|
|
|
) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if (
|
|
|
|
|
_session == null
|
|
|
|
|
or not _session.is_host()
|
|
|
|
|
or not _session.is_authenticated_peer(sender_id)
|
|
|
|
|
or session_id != _session.get_session_id()
|
|
|
|
|
or unlock_mask < 0
|
|
|
|
|
or (unlock_mask & ~PlayerArtUnlocks.ALL_UNLOCK_MASK) != 0
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_peer_art_entitlements[sender_id] = {
|
|
|
|
|
"has_kit": has_kit,
|
|
|
|
|
"unlock_mask": unlock_mask,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _peer_entitlement(peer_id: int) -> Dictionary:
|
|
|
|
|
if _session != null and peer_id == _session.get_local_peer_id():
|
|
|
|
|
return _local_entitlement()
|
|
|
|
|
return Dictionary(_peer_art_entitlements.get(peer_id, {}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _peer_owns_art_kit(peer_id: int) -> bool:
|
|
|
|
|
return bool(_peer_entitlement(peer_id).get("has_kit", false))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _peer_can_place_grid(peer_id: int, grid_size: int) -> bool:
|
|
|
|
|
var entitlement: Dictionary = _peer_entitlement(peer_id)
|
|
|
|
|
return (
|
|
|
|
|
bool(entitlement.get("has_kit", false))
|
|
|
|
|
and _mask_unlocks_grid(
|
|
|
|
|
int(entitlement.get("unlock_mask", 0)), grid_size
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _peer_can_edit(peer_id: int, request: Dictionary) -> bool:
|
|
|
|
|
var entitlement: Dictionary = _peer_entitlement(peer_id)
|
|
|
|
|
if not bool(entitlement.get("has_kit", false)):
|
|
|
|
|
return false
|
|
|
|
|
var unlock_mask: int = int(entitlement.get("unlock_mask", 0))
|
|
|
|
|
if not _mask_unlocks_brush(unlock_mask, int(request["brush_size"])):
|
|
|
|
|
return false
|
|
|
|
|
for value: Variant in request["edits"]:
|
|
|
|
|
var edit: Dictionary = value
|
|
|
|
|
var color_id := StringName(str(edit.get("color_id", "")))
|
|
|
|
|
if not color_id.is_empty() and not _mask_unlocks_color(
|
|
|
|
|
unlock_mask, color_id
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _mask_unlocks_color(unlock_mask: int, color_id: StringName) -> bool:
|
|
|
|
|
if color_id == SurfaceDrawingPalette.DEFAULT_COLOR_ID:
|
|
|
|
|
return true
|
|
|
|
|
for product_id: StringName in PlayerArtUnlocks.COLOR_PRODUCTS:
|
|
|
|
|
if (
|
|
|
|
|
PlayerArtUnlocks.color_id_for_product(product_id) == color_id
|
|
|
|
|
and _mask_owns_product(unlock_mask, product_id)
|
|
|
|
|
):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _mask_unlocks_brush(unlock_mask: int, brush_size: int) -> bool:
|
|
|
|
|
if brush_size == PlayerArtUnlocks.BASE_BRUSH_SIZE:
|
|
|
|
|
return true
|
|
|
|
|
for product_id: StringName in PlayerArtUnlocks.BRUSH_PRODUCTS:
|
|
|
|
|
if (
|
|
|
|
|
PlayerArtUnlocks.brush_size_for_product(product_id) == brush_size
|
|
|
|
|
and _mask_owns_product(unlock_mask, product_id)
|
|
|
|
|
):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _mask_unlocks_grid(unlock_mask: int, grid_size: int) -> bool:
|
|
|
|
|
if grid_size == PlayerArtUnlocks.BASE_GRID_SIZE:
|
|
|
|
|
return true
|
|
|
|
|
for product_id: StringName in PlayerArtUnlocks.GRID_PRODUCTS:
|
|
|
|
|
if (
|
|
|
|
|
PlayerArtUnlocks.grid_size_for_product(product_id) == grid_size
|
|
|
|
|
and _mask_owns_product(unlock_mask, product_id)
|
|
|
|
|
):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _mask_owns_product(unlock_mask: int, product_id: StringName) -> bool:
|
|
|
|
|
var bit: int = PlayerArtUnlocks.get_product_bit(product_id)
|
|
|
|
|
return bit >= 0 and (unlock_mask & (1 << bit)) != 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func request_canvas_at_surface(
|
|
|
|
|
origin: Vector3,
|
|
|
|
|
normal: Vector3,
|
|
|
|
|
tangent: Vector3,
|
|
|
|
|
) -> bool:
|
2026-08-02 00:45:07 -04:00
|
|
|
if (
|
2026-08-13 15:24:23 -04:00
|
|
|
not can_activate()
|
2026-08-02 00:45:07 -04:00
|
|
|
or _art_unlocks == null
|
|
|
|
|
or not _art_unlocks.is_grid_size_unlocked(_grid_size)
|
|
|
|
|
or normal.is_zero_approx()
|
|
|
|
|
or tangent.is_zero_approx()
|
|
|
|
|
):
|
2026-08-01 22:55:39 -04:00
|
|
|
return false
|
|
|
|
|
var data: Dictionary = {
|
|
|
|
|
"request_id": _new_request_id("canvas"),
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"origin": SurfaceDrawingProtocol.vector_to_array(origin),
|
|
|
|
|
"normal": SurfaceDrawingProtocol.vector_to_array(normal.normalized()),
|
|
|
|
|
"tangent": SurfaceDrawingProtocol.vector_to_array(tangent.normalized()),
|
2026-08-02 00:45:07 -04:00
|
|
|
"width": _grid_size,
|
|
|
|
|
"height": _grid_size,
|
2026-08-01 22:55:39 -04:00
|
|
|
"cell_size": SurfaceDrawingProtocol.CELL_SIZE,
|
|
|
|
|
}
|
|
|
|
|
if _session.is_host():
|
|
|
|
|
_handle_canvas_request(_session.get_local_peer_id(), data)
|
|
|
|
|
else:
|
|
|
|
|
submit_canvas_request.rpc_id(1, data)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func request_cell_edits(
|
|
|
|
|
canvas_id: String,
|
|
|
|
|
edits: Array[Dictionary],
|
|
|
|
|
stroke_id: String = "",
|
|
|
|
|
) -> bool:
|
2026-08-02 00:45:07 -04:00
|
|
|
if (
|
2026-08-13 15:24:23 -04:00
|
|
|
not can_activate()
|
2026-08-02 00:45:07 -04:00
|
|
|
or canvas_id.is_empty()
|
|
|
|
|
or edits.is_empty()
|
|
|
|
|
):
|
2026-08-01 22:55:39 -04:00
|
|
|
return false
|
|
|
|
|
var resolved_stroke_id: String = stroke_id
|
|
|
|
|
if resolved_stroke_id.is_empty():
|
|
|
|
|
resolved_stroke_id = _new_request_id("stroke")
|
|
|
|
|
_last_local_stroke_id = resolved_stroke_id
|
|
|
|
|
var data: Dictionary = {
|
|
|
|
|
"request_id": _new_request_id("edit"),
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"stroke_id": resolved_stroke_id,
|
2026-08-02 00:45:07 -04:00
|
|
|
"brush_size": _brush_size,
|
2026-08-01 22:55:39 -04:00
|
|
|
"edits": edits,
|
|
|
|
|
}
|
|
|
|
|
if not SurfaceDrawingProtocol.validate_edit_request(data):
|
|
|
|
|
return false
|
|
|
|
|
if _session.is_host():
|
|
|
|
|
_handle_edit_request(_session.get_local_peer_id(), data)
|
|
|
|
|
else:
|
|
|
|
|
submit_edit_request.rpc_id(1, data)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func request_guide_visibility(
|
|
|
|
|
canvas_id: String,
|
|
|
|
|
should_be_visible: bool,
|
|
|
|
|
should_finalize: bool = false,
|
|
|
|
|
) -> bool:
|
2026-08-13 15:24:23 -04:00
|
|
|
if not can_activate() or canvas_id.is_empty():
|
2026-08-01 22:55:39 -04:00
|
|
|
return false
|
|
|
|
|
var data: Dictionary = {
|
|
|
|
|
"request_id": _new_request_id("guide"),
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"guide_visible": should_be_visible and not should_finalize,
|
|
|
|
|
"finalized": should_finalize,
|
|
|
|
|
}
|
|
|
|
|
if not SurfaceDrawingProtocol.validate_guide_request(data):
|
|
|
|
|
return false
|
|
|
|
|
if _session.is_host():
|
|
|
|
|
_handle_guide_request(_session.get_local_peer_id(), data)
|
|
|
|
|
else:
|
|
|
|
|
submit_guide_request.rpc_id(1, data)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func request_undo_last_stroke() -> bool:
|
2026-08-02 00:45:07 -04:00
|
|
|
if (
|
2026-08-13 15:24:23 -04:00
|
|
|
not can_activate()
|
2026-08-02 00:45:07 -04:00
|
|
|
or _last_local_stroke_id.is_empty()
|
|
|
|
|
):
|
2026-08-01 22:55:39 -04:00
|
|
|
_emit_hud_state("nothing to undo")
|
|
|
|
|
return false
|
|
|
|
|
var data: Dictionary = {
|
|
|
|
|
"request_id": _new_request_id("undo"),
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"stroke_id": _last_local_stroke_id,
|
|
|
|
|
}
|
|
|
|
|
if not SurfaceDrawingProtocol.validate_undo_request(data):
|
|
|
|
|
return false
|
|
|
|
|
_last_local_stroke_id = ""
|
|
|
|
|
if _session.is_host():
|
|
|
|
|
_handle_undo_request(_session.get_local_peer_id(), data)
|
|
|
|
|
else:
|
|
|
|
|
submit_undo_request.rpc_id(1, data)
|
|
|
|
|
_emit_hud_state("undoing last stroke...")
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_canvas_count() -> int:
|
|
|
|
|
return _canvas_states.size()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_painted_cell_count() -> int:
|
|
|
|
|
var count: int = 0
|
|
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
count += (state.get("cells", []) as Array).size()
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func clear_session_artwork() -> bool:
|
|
|
|
|
if not _drawing_available() or not _session.is_host():
|
|
|
|
|
return false
|
|
|
|
|
_clear_session_artwork_state()
|
|
|
|
|
for peer_id: int in _session.get_authenticated_peer_ids():
|
|
|
|
|
if (
|
|
|
|
|
peer_id != _session.get_local_peer_id()
|
|
|
|
|
and _session.peer_supports_capability(
|
|
|
|
|
peer_id, SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
receive_session_artwork_reset.rpc_id(
|
|
|
|
|
peer_id, _session.get_session_id()
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_canvas_ids() -> Array[String]:
|
|
|
|
|
var result: Array[String] = []
|
|
|
|
|
for canvas_id: String in _canvas_states:
|
|
|
|
|
result.append(canvas_id)
|
|
|
|
|
result.sort()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_canvas_state(canvas_id: String) -> Dictionary:
|
|
|
|
|
return Dictionary(_canvas_states.get(canvas_id, {})).duplicate(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
|
|
|
if not _active:
|
|
|
|
|
return
|
2026-08-13 15:24:23 -04:00
|
|
|
if not can_activate():
|
|
|
|
|
deactivate()
|
|
|
|
|
return
|
2026-08-01 22:55:39 -04:00
|
|
|
_update_aim()
|
|
|
|
|
if _placing_grid:
|
|
|
|
|
return
|
|
|
|
|
if _painting:
|
|
|
|
|
_submit_current_brush(false)
|
|
|
|
|
elif _erasing:
|
|
|
|
|
_submit_current_brush(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _drawing_available() -> bool:
|
|
|
|
|
return (
|
|
|
|
|
_session != null
|
|
|
|
|
and _session.is_gameplay_session_active()
|
|
|
|
|
and (
|
|
|
|
|
_session.is_host()
|
|
|
|
|
or _session.supports_server_capability(
|
|
|
|
|
SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_aim() -> void:
|
|
|
|
|
_aim_hit = _raycast_from_pointer()
|
|
|
|
|
var previous_canvas_id: String = _selected_canvas_id
|
|
|
|
|
var found_canvas_id: String = ""
|
|
|
|
|
var nearest_plane_distance: float = INF
|
|
|
|
|
if not _aim_hit.is_empty():
|
|
|
|
|
var hit_position: Vector3 = _aim_hit["position"]
|
|
|
|
|
for canvas_id: String in _canvas_nodes:
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes[canvas_id]
|
|
|
|
|
if canvas.is_finalized():
|
|
|
|
|
continue
|
|
|
|
|
var plane_distance: float = canvas.get_surface_plane_distance(
|
|
|
|
|
hit_position
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
canvas.contains_world_point(
|
|
|
|
|
hit_position,
|
|
|
|
|
SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05,
|
|
|
|
|
)
|
|
|
|
|
and plane_distance < nearest_plane_distance
|
|
|
|
|
):
|
|
|
|
|
found_canvas_id = canvas_id
|
|
|
|
|
nearest_plane_distance = plane_distance
|
|
|
|
|
_selected_canvas_id = found_canvas_id
|
|
|
|
|
if previous_canvas_id != _selected_canvas_id:
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_update_previews()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _raycast_from_pointer() -> Dictionary:
|
|
|
|
|
if _local_player == null or not is_instance_valid(_local_player):
|
|
|
|
|
return {}
|
2026-08-09 22:27:33 -04:00
|
|
|
var camera: Camera3D = _local_player.get_active_gameplay_camera()
|
2026-08-01 22:55:39 -04:00
|
|
|
if camera == null or not camera.current:
|
|
|
|
|
return {}
|
|
|
|
|
var from: Vector3 = camera.project_ray_origin(_pointer_screen_position)
|
|
|
|
|
var direction: Vector3 = camera.project_ray_normal(
|
|
|
|
|
_pointer_screen_position
|
|
|
|
|
).normalized()
|
|
|
|
|
var to: Vector3 = from + direction * MAX_DRAW_DISTANCE
|
|
|
|
|
var query := PhysicsRayQueryParameters3D.create(
|
|
|
|
|
from,
|
|
|
|
|
to,
|
|
|
|
|
SOLID_SURFACE_MASK,
|
|
|
|
|
)
|
|
|
|
|
query.collide_with_areas = false
|
|
|
|
|
query.collide_with_bodies = true
|
|
|
|
|
query.exclude = [_local_player.get_rid()]
|
|
|
|
|
var hit: Dictionary = camera.get_world_3d().direct_space_state.intersect_ray(query)
|
|
|
|
|
if hit.is_empty() or not _is_static_surface(hit.get("collider")):
|
|
|
|
|
return {}
|
|
|
|
|
return hit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_previews() -> void:
|
|
|
|
|
if _placing_grid:
|
|
|
|
|
_update_placement_preview()
|
|
|
|
|
_hide_brush_preview()
|
|
|
|
|
return
|
|
|
|
|
if _placement_preview != null:
|
|
|
|
|
_placement_preview.hide()
|
|
|
|
|
_update_brush_preview()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_placement_preview() -> void:
|
|
|
|
|
if _placement_preview == null or _aim_hit.is_empty():
|
|
|
|
|
if _placement_preview != null:
|
|
|
|
|
_placement_preview.hide()
|
|
|
|
|
return
|
|
|
|
|
var placement: Dictionary = _resolved_placement()
|
|
|
|
|
var normal: Vector3 = placement["normal"]
|
|
|
|
|
var hit_position: Vector3 = placement["origin"]
|
|
|
|
|
var tangent: Vector3 = placement["tangent"]
|
|
|
|
|
var bitangent: Vector3 = normal.cross(tangent).normalized()
|
|
|
|
|
_placement_preview.global_transform = Transform3D(
|
|
|
|
|
Basis(tangent, bitangent, normal),
|
|
|
|
|
hit_position + normal * GRID_PREVIEW_SURFACE_OFFSET,
|
|
|
|
|
)
|
|
|
|
|
if _placement_preview_material != null:
|
|
|
|
|
_placement_preview_material.albedo_color = (
|
|
|
|
|
Color(0.34, 1.0, 0.72, 0.88)
|
|
|
|
|
if bool(placement["snapped"])
|
|
|
|
|
else Color(0.46, 0.91, 0.95, 0.72)
|
|
|
|
|
)
|
|
|
|
|
_placement_preview.show()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_brush_preview() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_brush_preview == null
|
|
|
|
|
or _brush_preview_multimesh == null
|
|
|
|
|
or _aim_hit.is_empty()
|
|
|
|
|
or _selected_canvas_id.is_empty()
|
|
|
|
|
):
|
|
|
|
|
_hide_brush_preview()
|
|
|
|
|
return
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(
|
|
|
|
|
_selected_canvas_id
|
|
|
|
|
)
|
|
|
|
|
if canvas == null:
|
|
|
|
|
_hide_brush_preview()
|
|
|
|
|
return
|
|
|
|
|
var center: Vector2i = canvas.cell_at_world_point(_aim_hit["position"])
|
|
|
|
|
var cells: Array[Vector2i] = _brush_cells(canvas, center)
|
|
|
|
|
if cells.is_empty():
|
|
|
|
|
_hide_brush_preview()
|
|
|
|
|
return
|
|
|
|
|
var root_inverse: Transform3D = _drawing_root.global_transform.affine_inverse()
|
|
|
|
|
for index: int in range(cells.size()):
|
|
|
|
|
var cell: Vector2i = cells[index]
|
|
|
|
|
var world_transform: Transform3D = canvas.get_cell_surface_transform(
|
|
|
|
|
cell.x, cell.y, 0.94
|
|
|
|
|
)
|
|
|
|
|
_brush_preview_multimesh.set_instance_transform(
|
|
|
|
|
index, root_inverse * world_transform
|
|
|
|
|
)
|
|
|
|
|
_brush_preview_multimesh.visible_instance_count = cells.size()
|
|
|
|
|
_brush_preview.show()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _hide_brush_preview() -> void:
|
|
|
|
|
if _brush_preview_multimesh != null:
|
|
|
|
|
_brush_preview_multimesh.visible_instance_count = 0
|
|
|
|
|
if _brush_preview != null:
|
|
|
|
|
_brush_preview.hide()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _hide_previews() -> void:
|
|
|
|
|
_hide_brush_preview()
|
|
|
|
|
if _placement_preview != null:
|
|
|
|
|
_placement_preview.hide()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _resolved_placement() -> Dictionary:
|
|
|
|
|
var normal: Vector3 = _normalized_or(
|
|
|
|
|
_aim_hit.get("normal", Vector3.UP), Vector3.UP
|
|
|
|
|
)
|
|
|
|
|
var states: Array[Dictionary] = []
|
|
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
states.append(state)
|
|
|
|
|
return SurfaceDrawingPlacement.resolve(
|
|
|
|
|
_aim_hit.get("position", Vector3.ZERO),
|
|
|
|
|
normal,
|
|
|
|
|
_surface_tangent(normal),
|
|
|
|
|
states,
|
2026-08-02 00:45:07 -04:00
|
|
|
_grid_size,
|
2026-08-01 22:55:39 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _request_canvas_at_aim() -> void:
|
|
|
|
|
if _aim_hit.is_empty():
|
|
|
|
|
_emit_hud_state("aim at a solid surface before placing a grid")
|
|
|
|
|
return
|
|
|
|
|
var placement: Dictionary = _resolved_placement()
|
|
|
|
|
var normal: Vector3 = placement["normal"]
|
|
|
|
|
var origin: Vector3 = placement["origin"]
|
|
|
|
|
if not _selected_canvas_id.is_empty():
|
|
|
|
|
var existing_state: Dictionary = _canvas_states.get(
|
|
|
|
|
_selected_canvas_id, {}
|
|
|
|
|
)
|
|
|
|
|
if not existing_state.is_empty():
|
|
|
|
|
if not bool(existing_state.get("guide_visible", true)):
|
|
|
|
|
if request_guide_visibility(_selected_canvas_id, true):
|
|
|
|
|
if not _session.is_host():
|
|
|
|
|
_emit_hud_state("restoring grid guide...")
|
|
|
|
|
else:
|
|
|
|
|
_emit_hud_state("a shared grid already covers this area")
|
|
|
|
|
return
|
2026-08-02 00:45:07 -04:00
|
|
|
if _overlaps_existing_canvas(origin, normal, _grid_size):
|
2026-08-01 22:55:39 -04:00
|
|
|
_emit_hud_state("a shared grid already covers this area")
|
|
|
|
|
return
|
|
|
|
|
if request_canvas_at_surface(origin, normal, placement["tangent"]):
|
|
|
|
|
_emit_hud_state(
|
|
|
|
|
"placing snapped shared grid..."
|
|
|
|
|
if bool(placement["snapped"])
|
|
|
|
|
else "placing shared grid..."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func submit_canvas_request(data: Dictionary) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
|
|
|
|
_handle_canvas_request(sender_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_canvas_request(peer_id: int, data: Dictionary) -> void:
|
2026-08-02 00:45:07 -04:00
|
|
|
var requested_size: int = int(data.get("width", 0))
|
2026-08-01 22:55:39 -04:00
|
|
|
if (
|
|
|
|
|
not _session.is_host()
|
|
|
|
|
or not SurfaceDrawingProtocol.validate_canvas_request(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
or not _accept_request(peer_id, str(data["request_id"]))
|
2026-08-02 00:45:07 -04:00
|
|
|
or not _peer_can_place_grid(peer_id, requested_size)
|
2026-08-01 22:55:39 -04:00
|
|
|
or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES
|
|
|
|
|
or _active_canvas_count() >= SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES
|
2026-08-02 00:45:07 -04:00
|
|
|
or _allocated_grid_cells() + requested_size * requested_size
|
|
|
|
|
> SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS
|
2026-08-01 22:55:39 -04:00
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
|
|
|
var requested_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
data["origin"]
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
avatar == null
|
|
|
|
|
or avatar.global_position.distance_to(requested_origin)
|
|
|
|
|
> MAX_DRAW_DISTANCE + 2.0
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var requested_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
data["normal"]
|
|
|
|
|
).normalized()
|
|
|
|
|
var surface_hit: Dictionary = _validate_surface(
|
|
|
|
|
requested_origin, requested_normal
|
|
|
|
|
)
|
|
|
|
|
if surface_hit.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var normal: Vector3 = _normalized_or(
|
|
|
|
|
surface_hit.get("normal", requested_normal), requested_normal
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
if _overlaps_existing_canvas(
|
|
|
|
|
surface_hit["position"], normal, requested_size
|
|
|
|
|
):
|
2026-08-01 22:55:39 -04:00
|
|
|
return
|
|
|
|
|
var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
data["tangent"]
|
|
|
|
|
)
|
|
|
|
|
tangent = (tangent - normal * tangent.dot(normal)).normalized()
|
|
|
|
|
if tangent.is_zero_approx():
|
|
|
|
|
tangent = _surface_tangent(normal)
|
|
|
|
|
_canvas_sequence += 1
|
|
|
|
|
var canvas_id: String = "%s-%d" % [
|
|
|
|
|
_session.get_session_id().left(16), _canvas_sequence,
|
|
|
|
|
]
|
|
|
|
|
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
|
|
|
|
|
if record == null or not record.identity_authenticated:
|
|
|
|
|
return
|
|
|
|
|
var state: Dictionary = {
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"origin": SurfaceDrawingProtocol.vector_to_array(
|
|
|
|
|
surface_hit["position"]
|
|
|
|
|
),
|
|
|
|
|
"normal": SurfaceDrawingProtocol.vector_to_array(normal),
|
|
|
|
|
"tangent": SurfaceDrawingProtocol.vector_to_array(tangent),
|
2026-08-02 00:45:07 -04:00
|
|
|
"width": requested_size,
|
|
|
|
|
"height": requested_size,
|
2026-08-01 22:55:39 -04:00
|
|
|
"cell_size": SurfaceDrawingProtocol.CELL_SIZE,
|
|
|
|
|
"revision": 0,
|
|
|
|
|
"guide_visible": true,
|
|
|
|
|
"finalized": false,
|
|
|
|
|
"layer": _canvas_sequence,
|
|
|
|
|
"creator_fingerprint": record.identity_fingerprint,
|
|
|
|
|
"cells": [],
|
|
|
|
|
}
|
|
|
|
|
_apply_canvas_state(state)
|
|
|
|
|
_broadcast_canvas_state(state)
|
|
|
|
|
_emit_hud_state("shared grid placed • everyone can draw here")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func submit_guide_request(data: Dictionary) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
|
|
|
|
_handle_guide_request(sender_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_guide_request(peer_id: int, data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not _session.is_host()
|
|
|
|
|
or not SurfaceDrawingProtocol.validate_guide_request(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
or not _accept_request(peer_id, str(data["request_id"]))
|
2026-08-02 00:45:07 -04:00
|
|
|
or not _peer_owns_art_kit(peer_id)
|
2026-08-01 22:55:39 -04:00
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var canvas_id: String = str(data["canvas_id"])
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
|
|
|
if state.is_empty() or avatar == null:
|
|
|
|
|
return
|
|
|
|
|
var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"])
|
|
|
|
|
if avatar.global_position.distance_to(origin) > MAX_DRAW_DISTANCE + 2.0:
|
|
|
|
|
return
|
|
|
|
|
var was_finalized: bool = bool(state.get("finalized", false))
|
|
|
|
|
var finalized: bool = bool(data["finalized"])
|
|
|
|
|
if was_finalized:
|
|
|
|
|
return
|
|
|
|
|
var guide_visible: bool = bool(data["guide_visible"]) and not finalized
|
|
|
|
|
if (
|
|
|
|
|
bool(state.get("guide_visible", true)) == guide_visible
|
|
|
|
|
and was_finalized == finalized
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
state["revision"] = int(state["revision"]) + 1
|
|
|
|
|
state["guide_visible"] = guide_visible
|
|
|
|
|
state["finalized"] = finalized
|
|
|
|
|
_canvas_states[canvas_id] = state
|
|
|
|
|
var update: Dictionary = {
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"revision": int(state["revision"]),
|
|
|
|
|
"guide_visible": guide_visible,
|
|
|
|
|
"finalized": finalized,
|
|
|
|
|
}
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if canvas != null:
|
|
|
|
|
canvas.apply_guide_update(update)
|
|
|
|
|
_broadcast_guide_update(update)
|
|
|
|
|
_emit_hud_state(
|
|
|
|
|
"grid finalized • artwork remains editable through new grids"
|
|
|
|
|
if finalized
|
|
|
|
|
else "grid guide restored" if guide_visible else "grid guide hidden"
|
|
|
|
|
)
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _submit_current_brush(erasing: bool, force: bool = false) -> void:
|
|
|
|
|
if _selected_canvas_id.is_empty() or _aim_hit.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(
|
|
|
|
|
_selected_canvas_id
|
|
|
|
|
)
|
|
|
|
|
if canvas == null:
|
|
|
|
|
return
|
|
|
|
|
var center: Vector2i = canvas.cell_at_world_point(_aim_hit["position"])
|
|
|
|
|
if center.x < 0:
|
|
|
|
|
return
|
|
|
|
|
var stroke_key := Vector3i(
|
|
|
|
|
_selected_canvas_id.hash(), center.x, center.y
|
|
|
|
|
)
|
|
|
|
|
var now: int = Time.get_ticks_msec()
|
|
|
|
|
if (
|
|
|
|
|
not force
|
|
|
|
|
and (
|
|
|
|
|
stroke_key == _last_edited_cell
|
|
|
|
|
or now - _last_edit_msec < EDIT_INTERVAL_MSEC
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var edits: Array[Dictionary] = []
|
|
|
|
|
for cell: Vector2i in _brush_cells(canvas, center):
|
|
|
|
|
edits.append({
|
|
|
|
|
"x": cell.x,
|
|
|
|
|
"y": cell.y,
|
|
|
|
|
"color_id": "" if erasing else str(_current_color_id()),
|
|
|
|
|
})
|
|
|
|
|
if edits.is_empty():
|
|
|
|
|
return
|
|
|
|
|
_last_edited_cell = stroke_key
|
|
|
|
|
_last_edit_msec = now
|
|
|
|
|
if _active_stroke_id.is_empty():
|
|
|
|
|
_begin_stroke()
|
|
|
|
|
request_cell_edits(_selected_canvas_id, edits, _active_stroke_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _brush_cells(
|
|
|
|
|
canvas: SurfaceDrawingCanvas,
|
|
|
|
|
center: Vector2i,
|
|
|
|
|
) -> Array[Vector2i]:
|
|
|
|
|
var result: Array[Vector2i] = []
|
|
|
|
|
if canvas == null or center.x < 0 or center.y < 0:
|
|
|
|
|
return result
|
|
|
|
|
var start_offset: int = -floori(float(_brush_size - 1) * 0.5)
|
|
|
|
|
for y_offset: int in range(start_offset, start_offset + _brush_size):
|
|
|
|
|
for x_offset: int in range(start_offset, start_offset + _brush_size):
|
|
|
|
|
var cell := Vector2i(center.x + x_offset, center.y + y_offset)
|
|
|
|
|
if (
|
|
|
|
|
cell.x < 0
|
|
|
|
|
or cell.x >= canvas.grid_width
|
|
|
|
|
or cell.y < 0
|
|
|
|
|
or cell.y >= canvas.grid_height
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
result.append(cell)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func submit_edit_request(data: Dictionary) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
|
|
|
|
_handle_edit_request(sender_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_edit_request(peer_id: int, data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not _session.is_host()
|
|
|
|
|
or not SurfaceDrawingProtocol.validate_edit_request(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
or not _accept_request(peer_id, str(data["request_id"]))
|
2026-08-02 00:45:07 -04:00
|
|
|
or not _peer_can_edit(peer_id, data)
|
2026-08-01 22:55:39 -04:00
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var canvas_id: String = str(data["canvas_id"])
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
|
|
|
|
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
|
|
|
|
|
if (
|
|
|
|
|
state.is_empty()
|
|
|
|
|
or bool(state.get("finalized", false))
|
|
|
|
|
or avatar == null
|
|
|
|
|
or record == null
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var stroke_id: String = str(data["stroke_id"])
|
2026-08-02 00:45:07 -04:00
|
|
|
var grid_width: int = int(state["width"])
|
|
|
|
|
var grid_height: int = int(state["height"])
|
2026-08-01 22:55:39 -04:00
|
|
|
var mutations: Dictionary[String, Dictionary] = {}
|
|
|
|
|
for edit_value: Variant in data["edits"]:
|
|
|
|
|
var edit: Dictionary = edit_value
|
|
|
|
|
var x: int = int(edit["x"])
|
|
|
|
|
var y: int = int(edit["y"])
|
2026-08-02 00:45:07 -04:00
|
|
|
if x >= grid_width or y >= grid_height:
|
|
|
|
|
continue
|
2026-08-01 22:55:39 -04:00
|
|
|
var cell_position: Vector3 = _state_cell_position(
|
|
|
|
|
state, x, y
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
avatar.global_position.distance_to(cell_position)
|
|
|
|
|
> MAX_DRAW_DISTANCE + 2.0
|
|
|
|
|
or _validate_surface(
|
|
|
|
|
cell_position,
|
|
|
|
|
SurfaceDrawingProtocol.array_to_vector(state["normal"]),
|
|
|
|
|
).is_empty()
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
var color_id := StringName(str(edit["color_id"]))
|
|
|
|
|
var authoritative: Dictionary = {
|
|
|
|
|
"x": x,
|
|
|
|
|
"y": y,
|
|
|
|
|
"color_id": str(color_id),
|
|
|
|
|
"author_fingerprint": (
|
|
|
|
|
record.identity_fingerprint if not color_id.is_empty() else ""
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
_queue_cell_mutation(
|
|
|
|
|
mutations,
|
|
|
|
|
canvas_id,
|
|
|
|
|
authoritative,
|
|
|
|
|
peer_id,
|
|
|
|
|
stroke_id,
|
|
|
|
|
)
|
|
|
|
|
_queue_overlapping_finished_mutations(
|
|
|
|
|
mutations,
|
|
|
|
|
canvas_id,
|
|
|
|
|
cell_position,
|
|
|
|
|
peer_id,
|
|
|
|
|
stroke_id,
|
|
|
|
|
)
|
|
|
|
|
if mutations.is_empty():
|
|
|
|
|
return
|
|
|
|
|
_publish_cell_mutations(mutations)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _queue_cell_mutation(
|
|
|
|
|
mutations: Dictionary[String, Dictionary],
|
|
|
|
|
canvas_id: String,
|
|
|
|
|
edit: Dictionary,
|
|
|
|
|
peer_id: int,
|
|
|
|
|
stroke_id: String,
|
|
|
|
|
) -> void:
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
if state.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var x: int = int(edit["x"])
|
|
|
|
|
var y: int = int(edit["y"])
|
|
|
|
|
var cell_key: int = _cell_key_for_state(state, x, y)
|
|
|
|
|
var canvas_mutations: Dictionary = mutations.get(
|
|
|
|
|
canvas_id, {}
|
|
|
|
|
)
|
|
|
|
|
var current: Dictionary = (
|
|
|
|
|
_cell_value_from_edit(canvas_mutations[cell_key])
|
|
|
|
|
if canvas_mutations.has(cell_key)
|
|
|
|
|
else _state_cell(state, x, y)
|
|
|
|
|
)
|
|
|
|
|
var next: Dictionary = _cell_value_from_edit(edit)
|
|
|
|
|
if _same_cell_value(current, next):
|
|
|
|
|
return
|
|
|
|
|
_record_stroke_change(
|
|
|
|
|
peer_id, stroke_id, canvas_id, x, y, current, next
|
|
|
|
|
)
|
|
|
|
|
canvas_mutations[cell_key] = edit.duplicate(true)
|
|
|
|
|
mutations[canvas_id] = canvas_mutations
|
|
|
|
|
_cell_last_stroke[_mutation_key(canvas_id, x, y)] = stroke_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _queue_overlapping_finished_mutations(
|
|
|
|
|
mutations: Dictionary[String, Dictionary],
|
|
|
|
|
target_canvas_id: String,
|
|
|
|
|
world_position: Vector3,
|
|
|
|
|
peer_id: int,
|
|
|
|
|
stroke_id: String,
|
|
|
|
|
) -> void:
|
|
|
|
|
var target_state: Dictionary = _canvas_states.get(target_canvas_id, {})
|
|
|
|
|
if target_state.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var target_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
target_state["normal"]
|
|
|
|
|
).normalized()
|
|
|
|
|
for canvas_id: String in _canvas_states:
|
|
|
|
|
if canvas_id == target_canvas_id:
|
|
|
|
|
continue
|
|
|
|
|
var state: Dictionary = _canvas_states[canvas_id]
|
|
|
|
|
if not bool(state.get("finalized", false)):
|
|
|
|
|
continue
|
|
|
|
|
var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["normal"]
|
|
|
|
|
).normalized()
|
|
|
|
|
if normal.dot(target_normal) < 0.9:
|
|
|
|
|
continue
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if (
|
|
|
|
|
canvas == null
|
|
|
|
|
or not canvas.contains_world_point(
|
|
|
|
|
world_position,
|
|
|
|
|
SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05,
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
var center_cell: Vector2i = canvas.cell_at_world_point(world_position)
|
|
|
|
|
if center_cell.x < 0:
|
|
|
|
|
continue
|
|
|
|
|
for y_offset: int in range(-1, 2):
|
|
|
|
|
for x_offset: int in range(-1, 2):
|
|
|
|
|
var cell := Vector2i(
|
|
|
|
|
center_cell.x + x_offset,
|
|
|
|
|
center_cell.y + y_offset,
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
cell.x < 0
|
|
|
|
|
or cell.x >= canvas.grid_width
|
|
|
|
|
or cell.y < 0
|
|
|
|
|
or cell.y >= canvas.grid_height
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
var relative: Vector3 = (
|
|
|
|
|
canvas.get_cell_world_position(cell.x, cell.y)
|
|
|
|
|
- world_position
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
absf(relative.dot(canvas.get_surface_tangent()))
|
|
|
|
|
>= SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
or absf(relative.dot(canvas.get_surface_bitangent()))
|
|
|
|
|
>= SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
or _state_cell(state, cell.x, cell.y).is_empty()
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
_queue_cell_mutation(
|
|
|
|
|
mutations,
|
|
|
|
|
canvas_id,
|
|
|
|
|
{
|
|
|
|
|
"x": cell.x,
|
|
|
|
|
"y": cell.y,
|
|
|
|
|
"color_id": "",
|
|
|
|
|
"author_fingerprint": "",
|
|
|
|
|
},
|
|
|
|
|
peer_id,
|
|
|
|
|
stroke_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _record_stroke_change(
|
|
|
|
|
peer_id: int,
|
|
|
|
|
stroke_id: String,
|
|
|
|
|
canvas_id: String,
|
|
|
|
|
x: int,
|
|
|
|
|
y: int,
|
|
|
|
|
before: Dictionary,
|
|
|
|
|
after: Dictionary,
|
|
|
|
|
) -> void:
|
|
|
|
|
var history: Dictionary = _stroke_history_by_peer.get(peer_id, {})
|
|
|
|
|
if str(history.get("stroke_id", "")) != stroke_id:
|
|
|
|
|
history = {"stroke_id": stroke_id, "changes": {}}
|
|
|
|
|
var changes: Dictionary = history["changes"]
|
|
|
|
|
var key: String = _mutation_key(canvas_id, x, y)
|
|
|
|
|
var change: Dictionary = changes.get(key, {})
|
|
|
|
|
if change.is_empty():
|
|
|
|
|
change = {
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"x": x,
|
|
|
|
|
"y": y,
|
|
|
|
|
"before": before.duplicate(true),
|
|
|
|
|
"before_stroke": str(_cell_last_stroke.get(key, "")),
|
|
|
|
|
}
|
|
|
|
|
change["after"] = after.duplicate(true)
|
|
|
|
|
changes[key] = change
|
|
|
|
|
history["changes"] = changes
|
|
|
|
|
_stroke_history_by_peer[peer_id] = history
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _publish_cell_mutations(
|
|
|
|
|
mutations: Dictionary[String, Dictionary],
|
|
|
|
|
) -> void:
|
|
|
|
|
var canvas_ids: Array[String] = mutations.keys()
|
|
|
|
|
canvas_ids.sort()
|
|
|
|
|
for canvas_id: String in canvas_ids:
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
if state.is_empty():
|
|
|
|
|
continue
|
2026-08-02 00:45:07 -04:00
|
|
|
var cells: Dictionary[int, Dictionary] = _cells_by_key(
|
|
|
|
|
state["cells"], int(state["width"])
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
var canvas_mutations: Dictionary = mutations[canvas_id]
|
|
|
|
|
var cell_keys: Array[int] = []
|
|
|
|
|
for cell_key_value: Variant in canvas_mutations.keys():
|
|
|
|
|
cell_keys.append(int(cell_key_value))
|
|
|
|
|
cell_keys.sort()
|
|
|
|
|
var edits: Array[Dictionary] = []
|
|
|
|
|
for cell_key: int in cell_keys:
|
|
|
|
|
var edit: Dictionary = canvas_mutations[cell_key]
|
|
|
|
|
if str(edit["color_id"]).is_empty():
|
|
|
|
|
cells.erase(cell_key)
|
|
|
|
|
else:
|
|
|
|
|
cells[cell_key] = edit.duplicate(true)
|
|
|
|
|
edits.append(edit.duplicate(true))
|
|
|
|
|
if edits.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
state["revision"] = int(state["revision"]) + 1
|
|
|
|
|
state["cells"] = _sorted_cells(cells)
|
|
|
|
|
_canvas_states[canvas_id] = state
|
|
|
|
|
var update: Dictionary = {
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"canvas_id": canvas_id,
|
|
|
|
|
"revision": int(state["revision"]),
|
|
|
|
|
"edits": edits,
|
|
|
|
|
}
|
|
|
|
|
var local_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if local_canvas != null:
|
|
|
|
|
local_canvas.apply_update(update)
|
|
|
|
|
_broadcast_canvas_update(update)
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func submit_undo_request(data: Dictionary) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if _session.is_host() and _session.is_authenticated_peer(sender_id):
|
|
|
|
|
_handle_undo_request(sender_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_undo_request(peer_id: int, data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not _session.is_host()
|
|
|
|
|
or not SurfaceDrawingProtocol.validate_undo_request(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
or not _accept_request(peer_id, str(data["request_id"]))
|
2026-08-02 00:45:07 -04:00
|
|
|
or not _peer_owns_art_kit(peer_id)
|
2026-08-01 22:55:39 -04:00
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var stroke_id: String = str(data["stroke_id"])
|
|
|
|
|
var history: Dictionary = _stroke_history_by_peer.get(peer_id, {})
|
|
|
|
|
var mutations: Dictionary[String, Dictionary] = {}
|
|
|
|
|
var restored_count: int = 0
|
|
|
|
|
if str(history.get("stroke_id", "")) == stroke_id:
|
|
|
|
|
var changes: Dictionary = history.get("changes", {})
|
|
|
|
|
for mutation_key: String in changes:
|
|
|
|
|
if str(_cell_last_stroke.get(mutation_key, "")) != stroke_id:
|
|
|
|
|
continue
|
|
|
|
|
var change: Dictionary = changes[mutation_key]
|
|
|
|
|
var canvas_id: String = str(change["canvas_id"])
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
if state.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
var x: int = int(change["x"])
|
|
|
|
|
var y: int = int(change["y"])
|
|
|
|
|
var current: Dictionary = _state_cell(state, x, y)
|
|
|
|
|
if not _same_cell_value(current, change.get("after", {})):
|
|
|
|
|
continue
|
|
|
|
|
var before: Dictionary = change.get("before", {})
|
|
|
|
|
var restored: Dictionary = _authoritative_edit_from_value(
|
|
|
|
|
x, y, before
|
|
|
|
|
)
|
|
|
|
|
var canvas_mutations: Dictionary = mutations.get(
|
|
|
|
|
canvas_id, {}
|
|
|
|
|
)
|
|
|
|
|
canvas_mutations[_cell_key_for_state(state, x, y)] = restored
|
|
|
|
|
mutations[canvas_id] = canvas_mutations
|
|
|
|
|
var previous_stroke: String = str(
|
|
|
|
|
change.get("before_stroke", "")
|
|
|
|
|
)
|
|
|
|
|
if previous_stroke.is_empty():
|
|
|
|
|
_cell_last_stroke.erase(mutation_key)
|
|
|
|
|
else:
|
|
|
|
|
_cell_last_stroke[mutation_key] = previous_stroke
|
|
|
|
|
restored_count += 1
|
|
|
|
|
_stroke_history_by_peer.erase(peer_id)
|
|
|
|
|
if not mutations.is_empty():
|
|
|
|
|
_publish_cell_mutations(mutations)
|
|
|
|
|
_send_undo_result(peer_id, restored_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _send_undo_result(peer_id: int, restored_count: int) -> void:
|
|
|
|
|
if peer_id == _session.get_local_peer_id():
|
|
|
|
|
_receive_undo_result(restored_count)
|
|
|
|
|
else:
|
|
|
|
|
receive_undo_result.rpc_id(peer_id, restored_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"authority",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func receive_undo_result(restored_count: int) -> void:
|
|
|
|
|
_receive_undo_result(restored_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _receive_undo_result(restored_count: int) -> void:
|
|
|
|
|
_emit_hud_state(
|
|
|
|
|
"last stroke undone"
|
|
|
|
|
if restored_count > 0
|
|
|
|
|
else "last stroke changed elsewhere and could not be undone"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"authority",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func receive_canvas_state(data: Dictionary) -> void:
|
|
|
|
|
_apply_canvas_state(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"authority",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func receive_canvas_update(data: Dictionary) -> void:
|
|
|
|
|
_apply_canvas_update(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"authority",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func receive_guide_update(data: Dictionary) -> void:
|
|
|
|
|
_apply_guide_update(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"authority",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func receive_session_artwork_reset(session_id: String) -> void:
|
|
|
|
|
if session_id == _session.get_session_id():
|
|
|
|
|
_clear_session_artwork_state()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc(
|
|
|
|
|
"any_peer",
|
|
|
|
|
"call_remote",
|
|
|
|
|
"reliable",
|
|
|
|
|
SurfaceDrawingProtocol.RELIABLE_CHANNEL,
|
|
|
|
|
)
|
|
|
|
|
func request_canvas_snapshot(request_id: String) -> void:
|
|
|
|
|
var sender_id: int = multiplayer.get_remote_sender_id()
|
|
|
|
|
if (
|
|
|
|
|
not _session.is_host()
|
|
|
|
|
or not _session.is_authenticated_peer(sender_id)
|
|
|
|
|
or request_id.is_empty()
|
|
|
|
|
or request_id.length() > SurfaceDrawingProtocol.MAX_REQUEST_ID_LENGTH
|
|
|
|
|
or not _accept_request(sender_id, request_id)
|
|
|
|
|
or not _session.peer_supports_capability(
|
|
|
|
|
sender_id, SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
receive_canvas_state.rpc_id(sender_id, state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _apply_canvas_state(data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not SurfaceDrawingProtocol.validate_canvas_state(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var canvas_id: String = str(data["canvas_id"])
|
|
|
|
|
var previous_state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
if (
|
|
|
|
|
not previous_state.is_empty()
|
|
|
|
|
and int(data["revision"]) <= int(previous_state["revision"])
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_canvas_states[canvas_id] = data.duplicate(true)
|
|
|
|
|
var previous_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if previous_canvas != null:
|
|
|
|
|
previous_canvas.queue_free()
|
|
|
|
|
var canvas := SurfaceDrawingCanvas.new()
|
|
|
|
|
canvas.name = "Drawing_%s" % canvas_id
|
|
|
|
|
_drawing_root.add_child(canvas)
|
|
|
|
|
if not canvas.setup(data, _relationships, SOLID_SURFACE_MASK):
|
|
|
|
|
canvas.queue_free()
|
|
|
|
|
return
|
|
|
|
|
_canvas_nodes[canvas_id] = canvas
|
|
|
|
|
_refresh_stencil_visibility()
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
if (
|
|
|
|
|
_active
|
|
|
|
|
and str(data.get("creator_fingerprint", ""))
|
|
|
|
|
== _session.get_local_identity_fingerprint()
|
|
|
|
|
):
|
|
|
|
|
_emit_hud_state("shared grid placed • move and click to place another")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _apply_canvas_update(data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not SurfaceDrawingProtocol.validate_canvas_update(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var canvas_id: String = str(data["canvas_id"])
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if state.is_empty() or canvas == null or int(data["revision"]) <= int(state["revision"]):
|
|
|
|
|
return
|
2026-08-02 00:45:07 -04:00
|
|
|
var cells: Dictionary[int, Dictionary] = _cells_by_key(
|
|
|
|
|
state["cells"], int(state["width"])
|
|
|
|
|
)
|
|
|
|
|
var grid_width: int = int(state["width"])
|
|
|
|
|
var grid_height: int = int(state["height"])
|
2026-08-01 22:55:39 -04:00
|
|
|
for edit_value: Variant in data["edits"]:
|
|
|
|
|
var edit: Dictionary = edit_value
|
2026-08-02 00:45:07 -04:00
|
|
|
var x: int = int(edit["x"])
|
|
|
|
|
var y: int = int(edit["y"])
|
|
|
|
|
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
|
|
|
|
|
return
|
|
|
|
|
var key: int = y * grid_width + x
|
2026-08-01 22:55:39 -04:00
|
|
|
if str(edit["color_id"]).is_empty():
|
|
|
|
|
cells.erase(key)
|
|
|
|
|
else:
|
|
|
|
|
cells[key] = edit.duplicate(true)
|
|
|
|
|
state["revision"] = int(data["revision"])
|
|
|
|
|
state["cells"] = _sorted_cells(cells)
|
|
|
|
|
_canvas_states[canvas_id] = state
|
|
|
|
|
canvas.apply_update(data)
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _apply_guide_update(data: Dictionary) -> void:
|
|
|
|
|
if (
|
|
|
|
|
not SurfaceDrawingProtocol.validate_guide_update(data)
|
|
|
|
|
or str(data["session_id"]) != _session.get_session_id()
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var canvas_id: String = str(data["canvas_id"])
|
|
|
|
|
var state: Dictionary = _canvas_states.get(canvas_id, {})
|
|
|
|
|
var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id)
|
|
|
|
|
if (
|
|
|
|
|
state.is_empty()
|
|
|
|
|
or canvas == null
|
|
|
|
|
or int(data["revision"]) <= int(state["revision"])
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
state["revision"] = int(data["revision"])
|
|
|
|
|
state["finalized"] = bool(data["finalized"])
|
|
|
|
|
state["guide_visible"] = (
|
|
|
|
|
bool(data["guide_visible"]) and not bool(data["finalized"])
|
|
|
|
|
)
|
|
|
|
|
_canvas_states[canvas_id] = state
|
|
|
|
|
canvas.apply_guide_update(data)
|
|
|
|
|
if _active:
|
|
|
|
|
_emit_hud_state(
|
|
|
|
|
"grid finalized • new grids may overlap its artwork"
|
|
|
|
|
if bool(data["finalized"])
|
|
|
|
|
else "grid guide restored"
|
|
|
|
|
if bool(data["guide_visible"])
|
|
|
|
|
else "grid guide hidden • pixels now meet edge-to-edge"
|
|
|
|
|
)
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _broadcast_canvas_state(data: Dictionary) -> void:
|
|
|
|
|
for peer_id: int in _session.get_authenticated_peer_ids():
|
|
|
|
|
if peer_id == _session.get_local_peer_id():
|
|
|
|
|
continue
|
|
|
|
|
if _session.peer_supports_capability(
|
|
|
|
|
peer_id, SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
):
|
|
|
|
|
receive_canvas_state.rpc_id(peer_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _broadcast_canvas_update(data: Dictionary) -> void:
|
|
|
|
|
for peer_id: int in _session.get_authenticated_peer_ids():
|
|
|
|
|
if peer_id == _session.get_local_peer_id():
|
|
|
|
|
continue
|
|
|
|
|
if _session.peer_supports_capability(
|
|
|
|
|
peer_id, SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
):
|
|
|
|
|
receive_canvas_update.rpc_id(peer_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _broadcast_guide_update(data: Dictionary) -> void:
|
|
|
|
|
for peer_id: int in _session.get_authenticated_peer_ids():
|
|
|
|
|
if peer_id == _session.get_local_peer_id():
|
|
|
|
|
continue
|
|
|
|
|
if _session.peer_supports_capability(
|
|
|
|
|
peer_id, SurfaceDrawingProtocol.CAPABILITY
|
|
|
|
|
):
|
|
|
|
|
receive_guide_update.rpc_id(peer_id, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _validate_surface(origin: Vector3, normal: Vector3) -> Dictionary:
|
|
|
|
|
if _drawing_root == null or normal.is_zero_approx():
|
|
|
|
|
return {}
|
|
|
|
|
var world: World3D = _drawing_root.get_world_3d()
|
|
|
|
|
if world == null:
|
|
|
|
|
return {}
|
|
|
|
|
var direction: Vector3 = normal.normalized()
|
|
|
|
|
var query := PhysicsRayQueryParameters3D.create(
|
|
|
|
|
origin + direction * SURFACE_VALIDATION_DEPTH,
|
|
|
|
|
origin - direction * SURFACE_VALIDATION_DEPTH,
|
|
|
|
|
SOLID_SURFACE_MASK,
|
|
|
|
|
)
|
|
|
|
|
query.collide_with_areas = false
|
|
|
|
|
query.collide_with_bodies = true
|
|
|
|
|
var hit: Dictionary = world.direct_space_state.intersect_ray(query)
|
|
|
|
|
if hit.is_empty() or not _is_static_surface(hit.get("collider")):
|
|
|
|
|
return {}
|
|
|
|
|
var hit_normal: Vector3 = _normalized_or(
|
|
|
|
|
hit.get("normal", direction), direction
|
|
|
|
|
)
|
|
|
|
|
if absf(hit_normal.dot(direction)) < MIN_SURFACE_NORMAL_DOT:
|
|
|
|
|
return {}
|
|
|
|
|
return hit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _is_static_surface(value: Variant) -> bool:
|
|
|
|
|
return value is StaticBody3D
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _surface_tangent(normal: Vector3) -> Vector3:
|
|
|
|
|
var camera: Camera3D = (
|
2026-08-09 22:27:33 -04:00
|
|
|
_local_player.get_active_gameplay_camera()
|
|
|
|
|
if _local_player != null
|
|
|
|
|
else null
|
2026-08-01 22:55:39 -04:00
|
|
|
)
|
|
|
|
|
var candidate: Vector3 = (
|
|
|
|
|
camera.global_basis.x if camera != null else Vector3.RIGHT
|
|
|
|
|
)
|
|
|
|
|
candidate = (candidate - normal * candidate.dot(normal)).normalized()
|
|
|
|
|
if candidate.is_zero_approx():
|
|
|
|
|
candidate = Vector3.UP.cross(normal).normalized()
|
|
|
|
|
if candidate.is_zero_approx():
|
|
|
|
|
candidate = Vector3.RIGHT
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _state_cell_position(state: Dictionary, x: int, y: int) -> Vector3:
|
|
|
|
|
var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"])
|
|
|
|
|
var normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["normal"]
|
|
|
|
|
).normalized()
|
|
|
|
|
var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["tangent"]
|
|
|
|
|
).normalized()
|
|
|
|
|
var bitangent: Vector3 = normal.cross(tangent).normalized()
|
|
|
|
|
var size: float = float(state["cell_size"])
|
|
|
|
|
return (
|
|
|
|
|
origin
|
|
|
|
|
+ tangent * (float(x) + 0.5 - float(state["width"]) * 0.5) * size
|
|
|
|
|
+ bitangent * (float(y) + 0.5 - float(state["height"]) * 0.5) * size
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _overlaps_existing_canvas(
|
|
|
|
|
origin: Vector3,
|
|
|
|
|
normal: Vector3,
|
|
|
|
|
requested_size: int,
|
|
|
|
|
) -> bool:
|
2026-08-01 22:55:39 -04:00
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
if bool(state.get("finalized", false)):
|
|
|
|
|
continue
|
|
|
|
|
var existing_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["origin"]
|
|
|
|
|
)
|
|
|
|
|
var existing_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["normal"]
|
|
|
|
|
).normalized()
|
|
|
|
|
if absf(normal.dot(existing_normal)) < 0.85:
|
|
|
|
|
continue
|
|
|
|
|
var relative: Vector3 = origin - existing_origin
|
|
|
|
|
if absf(relative.dot(existing_normal)) > SURFACE_VALIDATION_DEPTH:
|
|
|
|
|
continue
|
|
|
|
|
var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector(
|
|
|
|
|
state["tangent"]
|
|
|
|
|
).normalized()
|
|
|
|
|
var bitangent: Vector3 = existing_normal.cross(tangent).normalized()
|
2026-08-02 00:45:07 -04:00
|
|
|
var width: float = (
|
|
|
|
|
(float(state["width"]) + float(requested_size))
|
|
|
|
|
* float(state["cell_size"])
|
|
|
|
|
* 0.5
|
|
|
|
|
)
|
|
|
|
|
var height: float = (
|
|
|
|
|
(float(state["height"]) + float(requested_size))
|
|
|
|
|
* float(state["cell_size"])
|
|
|
|
|
* 0.5
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
var clearance: float = float(state["cell_size"]) * 0.5
|
|
|
|
|
if (
|
|
|
|
|
absf(relative.dot(tangent)) < width - clearance
|
|
|
|
|
and absf(relative.dot(bitangent)) < height - clearance
|
|
|
|
|
):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _active_canvas_count() -> int:
|
|
|
|
|
var count: int = 0
|
|
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
if not bool(state.get("finalized", false)):
|
|
|
|
|
count += 1
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _allocated_grid_cells() -> int:
|
|
|
|
|
var count: int = 0
|
|
|
|
|
for state: Dictionary in _canvas_states.values():
|
|
|
|
|
count += int(state.get("width", 0)) * int(state.get("height", 0))
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func _create_brush_preview() -> void:
|
|
|
|
|
if _drawing_root == null:
|
|
|
|
|
return
|
|
|
|
|
var quad := QuadMesh.new()
|
|
|
|
|
quad.size = Vector2.ONE
|
|
|
|
|
_brush_preview_material = ShaderMaterial.new()
|
|
|
|
|
_brush_preview_material.shader = BrushHighlightShader
|
|
|
|
|
quad.material = _brush_preview_material
|
|
|
|
|
_brush_preview_multimesh = MultiMesh.new()
|
|
|
|
|
_brush_preview_multimesh.transform_format = MultiMesh.TRANSFORM_3D
|
|
|
|
|
_brush_preview_multimesh.mesh = quad
|
|
|
|
|
_brush_preview_multimesh.instance_count = (
|
|
|
|
|
SurfaceDrawingProtocol.MAX_EDITS_PER_REQUEST
|
|
|
|
|
)
|
|
|
|
|
_brush_preview_multimesh.visible_instance_count = 0
|
|
|
|
|
_brush_preview = MultiMeshInstance3D.new()
|
|
|
|
|
_brush_preview.name = "MarkerBrushPreview"
|
|
|
|
|
_brush_preview.multimesh = _brush_preview_multimesh
|
|
|
|
|
_brush_preview.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
|
|
|
_brush_preview.hide()
|
|
|
|
|
_drawing_root.add_child(_brush_preview)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _create_placement_preview() -> void:
|
2026-08-02 00:45:07 -04:00
|
|
|
_rebuild_placement_preview()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _rebuild_placement_preview() -> void:
|
2026-08-01 22:55:39 -04:00
|
|
|
if _drawing_root == null:
|
|
|
|
|
return
|
2026-08-02 00:45:07 -04:00
|
|
|
if _placement_preview != null:
|
|
|
|
|
_placement_preview.queue_free()
|
|
|
|
|
_placement_preview = null
|
2026-08-01 22:55:39 -04:00
|
|
|
var mesh := ImmediateMesh.new()
|
|
|
|
|
_placement_preview_material = StandardMaterial3D.new()
|
|
|
|
|
_placement_preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
|
|
|
_placement_preview_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
|
|
|
_placement_preview_material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
|
|
|
_placement_preview_material.albedo_color = Color(0.46, 0.91, 0.95, 0.72)
|
|
|
|
|
mesh.surface_begin(Mesh.PRIMITIVE_LINES, _placement_preview_material)
|
|
|
|
|
var half_width: float = (
|
2026-08-02 00:45:07 -04:00
|
|
|
float(_grid_size)
|
2026-08-01 22:55:39 -04:00
|
|
|
* SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
* 0.5
|
|
|
|
|
)
|
|
|
|
|
var half_height: float = (
|
2026-08-02 00:45:07 -04:00
|
|
|
float(_grid_size)
|
2026-08-01 22:55:39 -04:00
|
|
|
* SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
* 0.5
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
for x: int in range(_grid_size + 1):
|
2026-08-01 22:55:39 -04:00
|
|
|
var horizontal: float = (
|
|
|
|
|
-half_width + float(x) * SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
)
|
|
|
|
|
mesh.surface_add_vertex(Vector3(horizontal, -half_height, 0.0))
|
|
|
|
|
mesh.surface_add_vertex(Vector3(horizontal, half_height, 0.0))
|
2026-08-02 00:45:07 -04:00
|
|
|
for y: int in range(_grid_size + 1):
|
2026-08-01 22:55:39 -04:00
|
|
|
var vertical: float = (
|
|
|
|
|
-half_height + float(y) * SurfaceDrawingProtocol.CELL_SIZE
|
|
|
|
|
)
|
|
|
|
|
mesh.surface_add_vertex(Vector3(-half_width, vertical, 0.0))
|
|
|
|
|
mesh.surface_add_vertex(Vector3(half_width, vertical, 0.0))
|
|
|
|
|
mesh.surface_end()
|
|
|
|
|
_placement_preview = MeshInstance3D.new()
|
|
|
|
|
_placement_preview.name = "MarkerGridPlacementPreview"
|
|
|
|
|
_placement_preview.mesh = mesh
|
|
|
|
|
_placement_preview.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
|
|
|
_placement_preview.hide()
|
|
|
|
|
_drawing_root.add_child(_placement_preview)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _set_placement_mode(enabled: bool) -> void:
|
2026-08-02 09:20:44 -04:00
|
|
|
if not _active:
|
|
|
|
|
return
|
|
|
|
|
_clear_armed_guide_action(true)
|
|
|
|
|
if _placing_grid == enabled:
|
|
|
|
|
_emit_hud_state("")
|
2026-08-01 22:55:39 -04:00
|
|
|
return
|
|
|
|
|
_placing_grid = enabled
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_selected_canvas_id = ""
|
|
|
|
|
_update_aim()
|
|
|
|
|
_refresh_stencil_visibility()
|
|
|
|
|
_emit_hud_state(
|
|
|
|
|
(
|
|
|
|
|
"click place/restore • shift hide • ctrl shift finish • shift scroll zoom"
|
|
|
|
|
if _placing_grid
|
|
|
|
|
else "click draw • shift click erase • ctrl z undo • shift scroll zoom"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 09:20:44 -04:00
|
|
|
func _execute_armed_guide_action() -> void:
|
|
|
|
|
var action: int = _armed_guide_action
|
|
|
|
|
match action:
|
|
|
|
|
GuideAction.HIDE:
|
|
|
|
|
_remove_selected_guide()
|
|
|
|
|
GuideAction.RESTORE:
|
|
|
|
|
_restore_selected_guide()
|
|
|
|
|
GuideAction.FINALIZE:
|
|
|
|
|
_finalize_selected_guide()
|
2026-08-13 14:53:39 -04:00
|
|
|
# Guide actions are persistent modes. Keep the selected action armed so
|
|
|
|
|
# multiple grids can be handled without returning to the toolbar after
|
|
|
|
|
# every click; selecting the active toggle again explicitly exits it.
|
|
|
|
|
_emit_hud_state(_guide_action_status(action))
|
2026-08-02 09:20:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _cancel_armed_guide_action() -> void:
|
|
|
|
|
if _armed_guide_action == GuideAction.NONE:
|
|
|
|
|
return
|
|
|
|
|
_clear_armed_guide_action(true)
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _clear_armed_guide_action(restore_tool: bool) -> void:
|
|
|
|
|
if _armed_guide_action == GuideAction.NONE:
|
|
|
|
|
return
|
|
|
|
|
_armed_guide_action = GuideAction.NONE
|
|
|
|
|
if restore_tool:
|
|
|
|
|
_placing_grid = _armed_return_placement_mode
|
|
|
|
|
_eraser_mode = _armed_return_eraser_mode
|
|
|
|
|
_armed_return_placement_mode = false
|
|
|
|
|
_armed_return_eraser_mode = false
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_update_aim()
|
|
|
|
|
_refresh_stencil_visibility()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _guide_action_verb(action: int) -> String:
|
|
|
|
|
match action:
|
|
|
|
|
GuideAction.HIDE:
|
|
|
|
|
return "hide"
|
|
|
|
|
GuideAction.RESTORE:
|
2026-08-13 14:53:39 -04:00
|
|
|
return "show"
|
2026-08-02 09:20:44 -04:00
|
|
|
GuideAction.FINALIZE:
|
|
|
|
|
return "finish"
|
|
|
|
|
return "use"
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 14:53:39 -04:00
|
|
|
func _guide_action_status(action: int) -> String:
|
|
|
|
|
return "%s grid mode • click grids • toggle again to exit" % (
|
|
|
|
|
_guide_action_verb(action).capitalize()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func _remove_selected_guide() -> void:
|
|
|
|
|
if _selected_canvas_id.is_empty():
|
|
|
|
|
_emit_hud_state("aim at a placed grid before removing its guide")
|
|
|
|
|
return
|
|
|
|
|
var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
|
|
|
|
|
if state.is_empty():
|
|
|
|
|
return
|
|
|
|
|
if not bool(state.get("guide_visible", true)):
|
|
|
|
|
_emit_hud_state("this grid guide is hidden • click to restore")
|
|
|
|
|
return
|
|
|
|
|
if request_guide_visibility(_selected_canvas_id, false):
|
|
|
|
|
if not _session.is_host():
|
|
|
|
|
_emit_hud_state("hiding grid guide...")
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 09:20:44 -04:00
|
|
|
func _restore_selected_guide() -> void:
|
|
|
|
|
if _selected_canvas_id.is_empty():
|
|
|
|
|
_emit_hud_state("aim at a hidden grid before restoring its guide")
|
|
|
|
|
return
|
|
|
|
|
var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
|
|
|
|
|
if state.is_empty() or bool(state.get("finalized", false)):
|
|
|
|
|
return
|
|
|
|
|
if bool(state.get("guide_visible", true)):
|
|
|
|
|
_emit_hud_state("this grid guide is already visible")
|
|
|
|
|
return
|
|
|
|
|
if request_guide_visibility(_selected_canvas_id, true):
|
|
|
|
|
if not _session.is_host():
|
|
|
|
|
_emit_hud_state("restoring grid guide...")
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func _finalize_selected_guide() -> void:
|
|
|
|
|
if _selected_canvas_id.is_empty():
|
|
|
|
|
_emit_hud_state("aim at an active grid before finishing it")
|
|
|
|
|
return
|
|
|
|
|
var state: Dictionary = _canvas_states.get(_selected_canvas_id, {})
|
|
|
|
|
if state.is_empty() or bool(state.get("finalized", false)):
|
|
|
|
|
return
|
|
|
|
|
if request_guide_visibility(_selected_canvas_id, false, true):
|
|
|
|
|
if not _session.is_host():
|
|
|
|
|
_emit_hud_state("finishing grid • artwork remains shared...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _clamped_pointer_position(value: Vector2) -> Vector2:
|
|
|
|
|
var viewport_size: Vector2 = get_viewport().get_visible_rect().size
|
|
|
|
|
return Vector2(
|
|
|
|
|
clampf(value.x, POINTER_EDGE_MARGIN, viewport_size.x - POINTER_EDGE_MARGIN),
|
|
|
|
|
clampf(value.y, POINTER_EDGE_MARGIN, viewport_size.y - POINTER_EDGE_MARGIN),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 16:25:16 -04:00
|
|
|
func _set_pointer_from_input(
|
|
|
|
|
event_position: Vector2,
|
|
|
|
|
pointer_screen_position: Vector2,
|
|
|
|
|
) -> void:
|
|
|
|
|
# GameUI receives input through a scaled SubViewport, so event_position is
|
|
|
|
|
# expressed in that UI viewport's render coordinates. Camera3D ray methods
|
|
|
|
|
# require coordinates in the camera's root viewport instead. GameUI supplies
|
|
|
|
|
# that authoritative window position; direct callers may still use an event
|
|
|
|
|
# position when they share this service's viewport.
|
|
|
|
|
var resolved_position: Vector2 = pointer_screen_position
|
|
|
|
|
if not _is_valid_pointer_screen_position(resolved_position):
|
|
|
|
|
resolved_position = event_position
|
|
|
|
|
_pointer_screen_position = _clamped_pointer_position(resolved_position)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _is_valid_pointer_screen_position(value: Vector2) -> bool:
|
|
|
|
|
return value.x >= 0.0 and value.y >= 0.0
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
func _cycle_color(direction: int) -> void:
|
|
|
|
|
if _color_ids.is_empty():
|
|
|
|
|
return
|
2026-08-02 09:20:44 -04:00
|
|
|
_clear_armed_guide_action(true)
|
|
|
|
|
_eraser_mode = false
|
2026-08-01 22:55:39 -04:00
|
|
|
_color_index = posmod(_color_index + direction, _color_ids.size())
|
|
|
|
|
_reset_stroke()
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _set_brush_size(value: int) -> void:
|
2026-08-02 00:45:07 -04:00
|
|
|
var requested: int = clampi(value, 1, 4)
|
|
|
|
|
if _art_unlocks != null and not _art_unlocks.is_brush_size_unlocked(
|
|
|
|
|
requested
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_brush_size = requested
|
2026-08-01 22:55:39 -04:00
|
|
|
_reset_stroke()
|
|
|
|
|
_emit_hud_state("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _current_color_id() -> StringName:
|
|
|
|
|
return (
|
|
|
|
|
_color_ids[_color_index]
|
|
|
|
|
if not _color_ids.is_empty()
|
|
|
|
|
else SurfaceDrawingPalette.DEFAULT_COLOR_ID
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _current_color() -> Color:
|
|
|
|
|
return SurfaceDrawingPalette.get_color(_current_color_id())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _emit_hud_state(status: String) -> void:
|
|
|
|
|
hud_state_changed.emit(
|
|
|
|
|
_active,
|
|
|
|
|
"place grid" if _placing_grid else "marker",
|
|
|
|
|
SurfaceDrawingPalette.get_display_name(_current_color_id()),
|
|
|
|
|
_current_color(),
|
|
|
|
|
_brush_size,
|
2026-08-02 00:45:07 -04:00
|
|
|
_grid_size,
|
2026-08-01 22:55:39 -04:00
|
|
|
status,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _refresh_stencil_visibility() -> void:
|
|
|
|
|
for canvas_id: String in _canvas_nodes:
|
|
|
|
|
_canvas_nodes[canvas_id].set_stencil_visible(_active)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _begin_stroke() -> void:
|
|
|
|
|
_active_stroke_id = _new_request_id("stroke")
|
|
|
|
|
_last_edited_cell = Vector3i(-1, -1, -1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _finish_stroke() -> void:
|
|
|
|
|
_painting = false
|
|
|
|
|
_erasing = false
|
|
|
|
|
_active_stroke_id = ""
|
|
|
|
|
_last_edited_cell = Vector3i(-1, -1, -1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _reset_stroke() -> void:
|
|
|
|
|
_finish_stroke()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _new_request_id(prefix: String) -> String:
|
|
|
|
|
_request_sequence += 1
|
|
|
|
|
return "%s-%d-%d" % [prefix, Time.get_ticks_msec(), _request_sequence]
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _cells_by_key(
|
|
|
|
|
values: Array,
|
|
|
|
|
grid_width: int,
|
|
|
|
|
) -> Dictionary[int, Dictionary]:
|
2026-08-01 22:55:39 -04:00
|
|
|
var result: Dictionary[int, Dictionary] = {}
|
|
|
|
|
for value: Variant in values:
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var cell: Dictionary = value
|
|
|
|
|
var key: int = (
|
2026-08-02 00:45:07 -04:00
|
|
|
int(cell.get("y", -1)) * grid_width
|
2026-08-01 22:55:39 -04:00
|
|
|
+ int(cell.get("x", -1))
|
|
|
|
|
)
|
|
|
|
|
result[key] = cell.duplicate(true)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary:
|
2026-08-02 00:45:07 -04:00
|
|
|
var cells: Dictionary[int, Dictionary] = _cells_by_key(
|
|
|
|
|
state.get("cells", []), int(state["width"])
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
return Dictionary(
|
|
|
|
|
cells.get(_cell_key_for_state(state, x, y), {})
|
|
|
|
|
).duplicate(true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _cell_value_from_edit(edit: Dictionary) -> Dictionary:
|
|
|
|
|
if edit.is_empty() or str(edit.get("color_id", "")).is_empty():
|
|
|
|
|
return {}
|
|
|
|
|
return {
|
|
|
|
|
"color_id": str(edit["color_id"]),
|
|
|
|
|
"author_fingerprint": str(edit.get("author_fingerprint", "")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _authoritative_edit_from_value(
|
|
|
|
|
x: int,
|
|
|
|
|
y: int,
|
|
|
|
|
value: Dictionary,
|
|
|
|
|
) -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"x": x,
|
|
|
|
|
"y": y,
|
|
|
|
|
"color_id": str(value.get("color_id", "")),
|
|
|
|
|
"author_fingerprint": str(value.get("author_fingerprint", "")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _same_cell_value(first: Dictionary, second: Dictionary) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
str(first.get("color_id", "")) == str(second.get("color_id", ""))
|
|
|
|
|
and str(first.get("author_fingerprint", ""))
|
|
|
|
|
== str(second.get("author_fingerprint", ""))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _cell_key_for_state(state: Dictionary, x: int, y: int) -> int:
|
|
|
|
|
return y * int(state["width"]) + x
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _mutation_key(canvas_id: String, x: int, y: int) -> String:
|
|
|
|
|
return "%s:%d:%d" % [canvas_id, x, y]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _sorted_cells(values: Dictionary[int, Dictionary]) -> Array[Dictionary]:
|
|
|
|
|
var keys: Array[int] = values.keys()
|
|
|
|
|
keys.sort()
|
|
|
|
|
var result: Array[Dictionary] = []
|
|
|
|
|
for key: int in keys:
|
|
|
|
|
result.append(values[key].duplicate(true))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _normalized_or(value: Variant, fallback: Vector3) -> Vector3:
|
|
|
|
|
var vector: Vector3 = value if typeof(value) == TYPE_VECTOR3 else fallback
|
|
|
|
|
return fallback if vector.is_zero_approx() else vector.normalized()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _accept_request(peer_id: int, request_id: String) -> bool:
|
|
|
|
|
var recent_ids: PackedStringArray = _peer_request_ids.get(
|
|
|
|
|
peer_id, PackedStringArray()
|
|
|
|
|
)
|
|
|
|
|
if request_id in recent_ids:
|
|
|
|
|
return false
|
|
|
|
|
var now: int = Time.get_ticks_msec()
|
|
|
|
|
var request_times: PackedInt64Array = _peer_request_times.get(
|
|
|
|
|
peer_id, PackedInt64Array()
|
|
|
|
|
)
|
|
|
|
|
while (
|
|
|
|
|
not request_times.is_empty()
|
|
|
|
|
and now - request_times[0] >= REQUEST_WINDOW_MSEC
|
|
|
|
|
):
|
|
|
|
|
request_times.remove_at(0)
|
|
|
|
|
if request_times.size() >= MAX_REQUESTS_PER_WINDOW:
|
|
|
|
|
_peer_request_times[peer_id] = request_times
|
|
|
|
|
return false
|
|
|
|
|
request_times.append(now)
|
|
|
|
|
recent_ids.append(request_id)
|
|
|
|
|
while recent_ids.size() > MAX_RECENT_REQUEST_IDS:
|
|
|
|
|
recent_ids.remove_at(0)
|
|
|
|
|
_peer_request_times[peer_id] = request_times
|
|
|
|
|
_peer_request_ids[peer_id] = recent_ids
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_peer_removed(peer_id: int) -> void:
|
|
|
|
|
_peer_request_times.erase(peer_id)
|
|
|
|
|
_peer_request_ids.erase(peer_id)
|
|
|
|
|
_stroke_history_by_peer.erase(peer_id)
|
2026-08-02 00:45:07 -04:00
|
|
|
_peer_art_entitlements.erase(peer_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_local_art_entitlement_changed() -> void:
|
2026-08-13 15:24:23 -04:00
|
|
|
if _active and not can_activate():
|
2026-08-02 00:45:07 -04:00
|
|
|
deactivate()
|
|
|
|
|
_publish_local_entitlement()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_local_art_unlocks_changed(_unlock_mask: int) -> void:
|
|
|
|
|
_refresh_local_unlocks()
|
|
|
|
|
_rebuild_placement_preview()
|
|
|
|
|
_publish_local_entitlement()
|
|
|
|
|
_emit_hud_state("")
|
2026-08-01 22:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_relationship_changed(_fingerprint: String) -> void:
|
|
|
|
|
for canvas: SurfaceDrawingCanvas in _canvas_nodes.values():
|
|
|
|
|
canvas.refresh_relationship_visibility()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_session_state_changed(state: NetworkSession.State) -> void:
|
|
|
|
|
if state == NetworkSession.State.JOINED_CLIENT:
|
2026-08-02 00:45:07 -04:00
|
|
|
_publish_local_entitlement()
|
2026-08-01 22:55:39 -04:00
|
|
|
request_canvas_snapshot.rpc_id(1, _new_request_id("snapshot"))
|
|
|
|
|
return
|
2026-08-02 00:45:07 -04:00
|
|
|
if state in [
|
|
|
|
|
NetworkSession.State.PRIVATE_HOST,
|
|
|
|
|
NetworkSession.State.OPEN_HOST,
|
|
|
|
|
]:
|
|
|
|
|
_publish_local_entitlement()
|
|
|
|
|
return
|
2026-08-01 22:55:39 -04:00
|
|
|
if state not in [
|
|
|
|
|
NetworkSession.State.INACTIVE,
|
|
|
|
|
NetworkSession.State.DISCONNECTING,
|
|
|
|
|
NetworkSession.State.CONNECTION_FAILED,
|
|
|
|
|
NetworkSession.State.SERVER_LOST,
|
|
|
|
|
]:
|
|
|
|
|
return
|
|
|
|
|
deactivate()
|
|
|
|
|
_clear_session_artwork_state()
|
|
|
|
|
_canvas_sequence = 0
|
|
|
|
|
_peer_request_times.clear()
|
|
|
|
|
_peer_request_ids.clear()
|
2026-08-02 00:45:07 -04:00
|
|
|
_peer_art_entitlements.clear()
|
2026-08-01 22:55:39 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _clear_session_artwork_state() -> void:
|
|
|
|
|
_canvas_states.clear()
|
|
|
|
|
for canvas: SurfaceDrawingCanvas in _canvas_nodes.values():
|
|
|
|
|
canvas.queue_free()
|
|
|
|
|
_canvas_nodes.clear()
|
|
|
|
|
_canvas_sequence = 0
|
|
|
|
|
_selected_canvas_id = ""
|
|
|
|
|
_stroke_history_by_peer.clear()
|
|
|
|
|
_cell_last_stroke.clear()
|
|
|
|
|
_last_local_stroke_id = ""
|
|
|
|
|
_hide_previews()
|
|
|
|
|
_emit_artwork_changed()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _emit_artwork_changed() -> void:
|
|
|
|
|
session_artwork_changed.emit(
|
|
|
|
|
get_canvas_count(), get_painted_cell_count()
|
|
|
|
|
)
|