From b92c55562dfad107d4aff445d4b79f68b2d3fdba Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 1 Aug 2026 22:55:39 -0400 Subject: [PATCH] Add collaborative surface drawing --- drawing/surface_drawing_canvas.gd | 393 ++++ drawing/surface_drawing_canvas.gd.uid | 1 + drawing/surface_drawing_highlight.gdshader | 12 + .../surface_drawing_highlight.gdshader.uid | 1 + drawing/surface_drawing_palette.gd | 57 + drawing/surface_drawing_palette.gd.uid | 1 + drawing/surface_drawing_placement.gd | 82 + drawing/surface_drawing_placement.gd.uid | 1 + drawing/surface_drawing_protocol.gd | 243 +++ drawing/surface_drawing_protocol.gd.uid | 1 + fishing/fishing_spot.gd | 9 + main/main.gd | 18 + main/main.tscn | 7 + network/network_player_list_service.gd | 44 + network/network_protocol.gd | 18 +- network/network_session.gd | 32 + network/network_surface_drawing_service.gd | 1731 +++++++++++++++++ .../network_surface_drawing_service.gd.uid | 1 + network/peer_registry.gd | 3 + .../surface_drawing_multiplayer_validation.gd | 252 +++ ...face_drawing_multiplayer_validation.gd.uid | 1 + tests/surface_drawing_runtime_validation.gd | 161 ++ .../surface_drawing_runtime_validation.gd.uid | 1 + tests/surface_drawing_validation.gd | 284 +++ tests/surface_drawing_validation.gd.uid | 1 + ui/game_ui.gd | 66 + ui/game_ui.tscn | 56 + ui/players_page.gd | 29 + 28 files changed, 3504 insertions(+), 2 deletions(-) create mode 100644 drawing/surface_drawing_canvas.gd create mode 100644 drawing/surface_drawing_canvas.gd.uid create mode 100644 drawing/surface_drawing_highlight.gdshader create mode 100644 drawing/surface_drawing_highlight.gdshader.uid create mode 100644 drawing/surface_drawing_palette.gd create mode 100644 drawing/surface_drawing_palette.gd.uid create mode 100644 drawing/surface_drawing_placement.gd create mode 100644 drawing/surface_drawing_placement.gd.uid create mode 100644 drawing/surface_drawing_protocol.gd create mode 100644 drawing/surface_drawing_protocol.gd.uid create mode 100644 network/network_surface_drawing_service.gd create mode 100644 network/network_surface_drawing_service.gd.uid create mode 100644 tests/surface_drawing_multiplayer_validation.gd create mode 100644 tests/surface_drawing_multiplayer_validation.gd.uid create mode 100644 tests/surface_drawing_runtime_validation.gd create mode 100644 tests/surface_drawing_runtime_validation.gd.uid create mode 100644 tests/surface_drawing_validation.gd create mode 100644 tests/surface_drawing_validation.gd.uid diff --git a/drawing/surface_drawing_canvas.gd b/drawing/surface_drawing_canvas.gd new file mode 100644 index 0000000..2d4f506 --- /dev/null +++ b/drawing/surface_drawing_canvas.gd @@ -0,0 +1,393 @@ +class_name SurfaceDrawingCanvas +extends Node3D + +const GUIDED_PIXEL_FILL: float = 0.88 +const FINISHED_PIXEL_FILL: float = 1.0 +const SURFACE_OFFSET: float = 0.012 +const LAYER_OFFSET_STEP: float = 0.0002 +const SURFACE_SAMPLE_DEPTH: float = 0.45 + +var canvas_id: String = "" +var grid_width: int = 0 +var grid_height: int = 0 +var cell_size: float = 0.0 +var revision: int = -1 +var creator_fingerprint: String = "" + +var _surface_origin: Vector3 +var _surface_normal: Vector3 +var _surface_tangent: Vector3 +var _surface_bitangent: Vector3 +var _cells: Dictionary[int, Dictionary] = {} +var _color_meshes: Dictionary[StringName, MultiMeshInstance3D] = {} +var _grid_instance: MeshInstance3D +var _guide_visible: bool = true +var _finalized: bool = false +var _layer: int = 0 +var _stencil_requested_visible: bool = false +var _relationships: PlayerRelationshipStore +var _solid_surface_mask: int = 1 + + +func setup( + data: Dictionary, + relationships: PlayerRelationshipStore, + solid_surface_mask: int = 1, +) -> bool: + if not SurfaceDrawingProtocol.validate_canvas_state(data): + return false + canvas_id = str(data["canvas_id"]) + grid_width = int(data["width"]) + grid_height = int(data["height"]) + cell_size = float(data["cell_size"]) + revision = int(data["revision"]) + creator_fingerprint = str(data["creator_fingerprint"]) + _finalized = bool(data.get("finalized", false)) + _guide_visible = bool(data.get("guide_visible", true)) and not _finalized + _layer = int(data.get("layer", 0)) + _surface_origin = SurfaceDrawingProtocol.array_to_vector(data["origin"]) + _surface_normal = SurfaceDrawingProtocol.array_to_vector( + data["normal"] + ).normalized() + _surface_tangent = SurfaceDrawingProtocol.array_to_vector( + data["tangent"] + ) + _surface_tangent = ( + _surface_tangent - _surface_normal * _surface_tangent.dot(_surface_normal) + ).normalized() + if _surface_normal.is_zero_approx() or _surface_tangent.is_zero_approx(): + return false + _surface_bitangent = _surface_normal.cross(_surface_tangent).normalized() + _relationships = relationships + _solid_surface_mask = solid_surface_mask + _cells.clear() + for cell_value: Variant in data["cells"]: + var cell: Dictionary = cell_value + _cells[_cell_key(int(cell["x"]), int(cell["y"]))] = cell.duplicate(true) + _build_grid() + _rebuild_pixels() + return true + + +func apply_update(data: Dictionary) -> bool: + if ( + not SurfaceDrawingProtocol.validate_canvas_update(data) + or str(data["canvas_id"]) != canvas_id + or int(data["revision"]) <= revision + ): + return false + for edit_value: Variant in data["edits"]: + var edit: Dictionary = edit_value + var key: int = _cell_key(int(edit["x"]), int(edit["y"])) + if str(edit["color_id"]).is_empty(): + _cells.erase(key) + else: + _cells[key] = edit.duplicate(true) + revision = int(data["revision"]) + _rebuild_pixels() + return true + + +func apply_guide_update(data: Dictionary) -> bool: + if ( + not SurfaceDrawingProtocol.validate_guide_update(data) + or str(data["canvas_id"]) != canvas_id + or int(data["revision"]) <= revision + ): + return false + revision = int(data["revision"]) + _finalized = bool(data["finalized"]) + _guide_visible = bool(data["guide_visible"]) and not _finalized + _refresh_grid_visibility() + _rebuild_pixels() + return true + + +func refresh_relationship_visibility() -> void: + _rebuild_pixels() + + +func set_stencil_visible(should_be_visible: bool) -> void: + _stencil_requested_visible = should_be_visible + _refresh_grid_visibility() + + +func is_guide_visible() -> bool: + return _guide_visible + + +func is_finalized() -> bool: + return _finalized + + +func get_rendered_pixel_size() -> float: + return cell_size * _pixel_fill() + + +func contains_world_point(world_point: Vector3, tolerance: float = 0.3) -> bool: + var relative: Vector3 = world_point - _surface_origin + if absf(relative.dot(_surface_normal)) > tolerance: + return false + var half_width: float = float(grid_width) * cell_size * 0.5 + var half_height: float = float(grid_height) * cell_size * 0.5 + var horizontal: float = relative.dot(_surface_tangent) + var vertical: float = relative.dot(_surface_bitangent) + return ( + horizontal >= -half_width + and horizontal < half_width + and vertical >= -half_height + and vertical < half_height + ) + + +func get_surface_plane_distance(world_point: Vector3) -> float: + return absf((world_point - _surface_origin).dot(_surface_normal)) + + +func cell_at_world_point(world_point: Vector3) -> Vector2i: + var relative: Vector3 = world_point - _surface_origin + var half_width: float = float(grid_width) * cell_size * 0.5 + var half_height: float = float(grid_height) * cell_size * 0.5 + var x: int = floori( + (relative.dot(_surface_tangent) + half_width) / cell_size + ) + var y: int = floori( + (relative.dot(_surface_bitangent) + half_height) / cell_size + ) + if x < 0 or x >= grid_width or y < 0 or y >= grid_height: + return Vector2i(-1, -1) + return Vector2i(x, y) + + +func get_cell_world_position(x: int, y: int) -> Vector3: + var horizontal: float = ( + (float(x) + 0.5 - float(grid_width) * 0.5) * cell_size + ) + var vertical: float = ( + (float(y) + 0.5 - float(grid_height) * 0.5) * cell_size + ) + return ( + _surface_origin + + _surface_tangent * horizontal + + _surface_bitangent * vertical + ) + + +func get_surface_normal() -> Vector3: + return _surface_normal + + +func get_surface_tangent() -> Vector3: + return _surface_tangent + + +func get_surface_bitangent() -> Vector3: + return _surface_bitangent + + +func get_cell_surface_transform( + x: int, + y: int, + fill: float = 0.94, +) -> Transform3D: + if x < 0 or x >= grid_width or y < 0 or y >= grid_height: + return Transform3D.IDENTITY + return global_transform * _sample_cell_transform(x, y, fill) + + +func get_authoritative_cells() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for value: Dictionary in _cells.values(): + result.append(value.duplicate(true)) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_key: int = _cell_key(int(a["x"]), int(a["y"])) + var b_key: int = _cell_key(int(b["x"]), int(b["y"])) + return a_key < b_key + ) + return result + + +func _build_grid() -> void: + if _grid_instance != null: + _grid_instance.queue_free() + var immediate := ImmediateMesh.new() + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.albedo_color = Color(0.75, 0.94, 0.96, 0.38) + material.no_depth_test = false + immediate.surface_begin(Mesh.PRIMITIVE_LINES, material) + var half_width: float = float(grid_width) * cell_size * 0.5 + var half_height: float = float(grid_height) * cell_size * 0.5 + for x: int in range(grid_width + 1): + var horizontal: float = -half_width + float(x) * cell_size + for y_segment: int in range(grid_height): + var start_vertical: float = ( + -half_height + float(y_segment) * cell_size + ) + _add_grid_vertex( + immediate, + _surface_origin + _surface_tangent * horizontal + + _surface_bitangent * start_vertical, + ) + _add_grid_vertex( + immediate, + _surface_origin + _surface_tangent * horizontal + + _surface_bitangent * (start_vertical + cell_size), + ) + for y: int in range(grid_height + 1): + var vertical: float = -half_height + float(y) * cell_size + for x_segment: int in range(grid_width): + var start_horizontal: float = ( + -half_width + float(x_segment) * cell_size + ) + _add_grid_vertex( + immediate, + _surface_origin + _surface_tangent * start_horizontal + + _surface_bitangent * vertical, + ) + _add_grid_vertex( + immediate, + _surface_origin + + _surface_tangent * (start_horizontal + cell_size) + + _surface_bitangent * vertical, + ) + immediate.surface_end() + _grid_instance = MeshInstance3D.new() + _grid_instance.name = "PixelGrid" + _grid_instance.mesh = immediate + _grid_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_grid_instance) + _refresh_grid_visibility() + + +func _add_grid_vertex(immediate: ImmediateMesh, world_point: Vector3) -> void: + var sampled: Dictionary = _sample_surface(world_point) + var point: Vector3 = sampled["position"] + var normal: Vector3 = sampled["normal"] + immediate.surface_add_vertex( + to_local(point + normal * (_surface_offset() * 1.5)) + ) + + +func _rebuild_pixels() -> void: + for instance: MultiMeshInstance3D in _color_meshes.values(): + instance.queue_free() + _color_meshes.clear() + var cells_by_color: Dictionary[StringName, Array] = {} + for cell: Dictionary in _cells.values(): + var author_fingerprint: String = str(cell.get("author_fingerprint", "")) + if ( + _relationships != null + and _relationships.is_blocked(author_fingerprint) + ): + continue + var color_id := StringName(str(cell.get("color_id", ""))) + if not SurfaceDrawingPalette.has_color(color_id): + continue + var color_cells: Array = cells_by_color.get(color_id, []) + color_cells.append(cell) + cells_by_color[color_id] = color_cells + for color_id: StringName in cells_by_color: + _create_color_mesh(color_id, cells_by_color[color_id]) + + +func _create_color_mesh(color_id: StringName, cells: Array) -> void: + if cells.is_empty(): + return + var quad := QuadMesh.new() + quad.size = Vector2.ONE + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = SurfaceDrawingPalette.get_color(color_id) + material.cull_mode = BaseMaterial3D.CULL_DISABLED + quad.material = material + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.mesh = quad + multimesh.instance_count = cells.size() + for cell_index: int in range(cells.size()): + var cell: Dictionary = cells[cell_index] + multimesh.set_instance_transform( + cell_index, + _sample_cell_transform( + int(cell["x"]), int(cell["y"]), _pixel_fill() + ), + ) + var instance := MultiMeshInstance3D.new() + instance.name = "Pixels_%s" % color_id + instance.multimesh = multimesh + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(instance) + _color_meshes[color_id] = instance + + +func _sample_cell_transform( + x: int, + y: int, + fill: float = GUIDED_PIXEL_FILL, +) -> Transform3D: + var expected: Vector3 = get_cell_world_position(x, y) + var sampled: Dictionary = _sample_surface(expected) + var point: Vector3 = sampled["position"] + var normal: Vector3 = sampled["normal"] + var tangent: Vector3 = ( + _surface_tangent - normal * _surface_tangent.dot(normal) + ).normalized() + if tangent.is_zero_approx(): + tangent = normal.cross(Vector3.UP).normalized() + if tangent.is_zero_approx(): + tangent = Vector3.RIGHT + var bitangent: Vector3 = normal.cross(tangent).normalized() + var pixel_size: float = cell_size * clampf(fill, 0.05, 1.0) + var basis := Basis( + tangent * pixel_size, + bitangent * pixel_size, + normal, + ) + return Transform3D( + basis, + to_local(point + normal * _surface_offset()), + ) + + +func _sample_surface(expected: Vector3) -> Dictionary: + var point: Vector3 = expected + var normal: Vector3 = _surface_normal + var world: World3D = get_world_3d() + if world != null: + var query := PhysicsRayQueryParameters3D.create( + expected + _surface_normal * SURFACE_SAMPLE_DEPTH, + expected - _surface_normal * SURFACE_SAMPLE_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 ( + not hit.is_empty() + and hit.get("collider") is StaticBody3D + ): + var hit_normal: Vector3 = hit.get("normal", _surface_normal) + if hit_normal.dot(_surface_normal) >= 0.35: + var hit_position: Vector3 = hit.get("position", expected) + point = hit_position + normal = hit_normal.normalized() + return {"position": point, "normal": normal} + + +func _pixel_fill() -> float: + return GUIDED_PIXEL_FILL if _guide_visible else FINISHED_PIXEL_FILL + + +func _surface_offset() -> float: + return SURFACE_OFFSET + float(_layer) * LAYER_OFFSET_STEP + + +func _refresh_grid_visibility() -> void: + if _grid_instance != null: + _grid_instance.visible = _stencil_requested_visible and _guide_visible + + +func _cell_key(x: int, y: int) -> int: + return y * grid_width + x diff --git a/drawing/surface_drawing_canvas.gd.uid b/drawing/surface_drawing_canvas.gd.uid new file mode 100644 index 0000000..9da19e3 --- /dev/null +++ b/drawing/surface_drawing_canvas.gd.uid @@ -0,0 +1 @@ +uid://cj2gq63l4mysl diff --git a/drawing/surface_drawing_highlight.gdshader b/drawing/surface_drawing_highlight.gdshader new file mode 100644 index 0000000..02dae33 --- /dev/null +++ b/drawing/surface_drawing_highlight.gdshader @@ -0,0 +1,12 @@ +shader_type spatial; +render_mode unshaded, cull_disabled, depth_draw_never; + +uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_nearest; + + +void fragment() { + vec3 surface_color = textureLod(screen_texture, SCREEN_UV, 0.0).rgb; + vec3 inverted_color = vec3(1.0) - surface_color; + ALBEDO = inverted_color; + EMISSION = inverted_color; +} diff --git a/drawing/surface_drawing_highlight.gdshader.uid b/drawing/surface_drawing_highlight.gdshader.uid new file mode 100644 index 0000000..aebbc9f --- /dev/null +++ b/drawing/surface_drawing_highlight.gdshader.uid @@ -0,0 +1 @@ +uid://k82me23ylsos diff --git a/drawing/surface_drawing_palette.gd b/drawing/surface_drawing_palette.gd new file mode 100644 index 0000000..c8bfc8e --- /dev/null +++ b/drawing/surface_drawing_palette.gd @@ -0,0 +1,57 @@ +class_name SurfaceDrawingPalette +extends RefCounted + +const DEFAULT_COLOR_ID: StringName = &"chalk_white" + +# Stable IDs are the network and future progression boundary. New colors may +# be appended without changing drawings created with an older palette. +const COLORS: Array[Dictionary] = [ + {"id": &"chalk_white", "name": "Chalk white", "color": Color("f5eed9")}, + {"id": &"ocean_teal", "name": "Ocean teal", "color": Color("35b9c7")}, + {"id": &"coral", "name": "Coral", "color": Color("ef5b62")}, + {"id": &"sunny", "name": "Sunny", "color": Color("ffd166")}, + {"id": &"leaf", "name": "Leaf", "color": Color("46c878")}, + {"id": &"blue", "name": "Blue", "color": Color("5596f6")}, + {"id": &"violet", "name": "Violet", "color": Color("b176e8")}, + {"id": &"charcoal", "name": "Charcoal", "color": Color("28251f")}, +] + + +static func has_color(color_id: StringName) -> bool: + return not get_entry(color_id).is_empty() + + +static func get_entry(color_id: StringName) -> Dictionary: + for entry: Dictionary in COLORS: + if StringName(entry.get("id", &"")) == color_id: + return entry + return {} + + +static func get_color(color_id: StringName) -> Color: + var entry: Dictionary = get_entry(color_id) + var value: Variant = entry.get("color", Color.WHITE) + return value if typeof(value) == TYPE_COLOR else Color.WHITE + + +static func get_display_name(color_id: StringName) -> String: + return str(get_entry(color_id).get("name", "Unknown")) + + +static func get_color_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for entry: Dictionary in COLORS: + result.append(StringName(entry.get("id", &""))) + return result + + +static func filter_unlocked_ids( + unlocked_ids: Array[StringName], +) -> Array[StringName]: + var result: Array[StringName] = [] + for color_id: StringName in unlocked_ids: + if has_color(color_id) and color_id not in result: + result.append(color_id) + if result.is_empty(): + result.append(DEFAULT_COLOR_ID) + return result diff --git a/drawing/surface_drawing_palette.gd.uid b/drawing/surface_drawing_palette.gd.uid new file mode 100644 index 0000000..f6ece03 --- /dev/null +++ b/drawing/surface_drawing_palette.gd.uid @@ -0,0 +1 @@ +uid://bk16cl6f3v887 diff --git a/drawing/surface_drawing_placement.gd b/drawing/surface_drawing_placement.gd new file mode 100644 index 0000000..01eaaa5 --- /dev/null +++ b/drawing/surface_drawing_placement.gd @@ -0,0 +1,82 @@ +class_name SurfaceDrawingPlacement +extends RefCounted + +const SNAP_DISTANCE: float = 0.55 +const SNAP_PLANE_DISTANCE: float = 0.18 +const SNAP_NORMAL_DOT: float = 0.94 + + +static func resolve( + origin: Vector3, + normal: Vector3, + fallback_tangent: Vector3, + canvas_states: Array[Dictionary], +) -> Dictionary: + var surface_normal: Vector3 = normal.normalized() + var surface_tangent: Vector3 = _projected_tangent( + fallback_tangent, surface_normal + ) + var best: Dictionary = { + "origin": origin, + "normal": surface_normal, + "tangent": surface_tangent, + "snapped": false, + } + var nearest_distance: float = SNAP_DISTANCE + for state: Dictionary in canvas_states: + if not SurfaceDrawingProtocol.validate_canvas_state(state): + continue + var anchor_normal: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["normal"] + ).normalized() + if anchor_normal.dot(surface_normal) < SNAP_NORMAL_DOT: + continue + var anchor_origin: Vector3 = SurfaceDrawingProtocol.array_to_vector( + state["origin"] + ) + var relative: Vector3 = origin - anchor_origin + if absf(relative.dot(anchor_normal)) > SNAP_PLANE_DISTANCE: + continue + var anchor_tangent: Vector3 = _projected_tangent( + SurfaceDrawingProtocol.array_to_vector(state["tangent"]), + anchor_normal, + ) + var anchor_bitangent: Vector3 = anchor_normal.cross( + anchor_tangent + ).normalized() + var width: float = float(state["width"]) * float(state["cell_size"]) + var height: float = float(state["height"]) * float(state["cell_size"]) + if width <= 0.0 or height <= 0.0: + continue + var horizontal_step: int = roundi(relative.dot(anchor_tangent) / width) + var vertical_step: int = roundi(relative.dot(anchor_bitangent) / height) + if horizontal_step == 0 and vertical_step == 0: + continue + var candidate: Vector3 = ( + anchor_origin + + anchor_tangent * float(horizontal_step) * width + + anchor_bitangent * float(vertical_step) * height + ) + var distance: float = candidate.distance_to(origin) + if distance > nearest_distance: + continue + nearest_distance = distance + best = { + "origin": candidate, + "normal": anchor_normal, + "tangent": anchor_tangent, + "snapped": true, + } + return best + + +static func _projected_tangent( + value: Vector3, + normal: Vector3, +) -> Vector3: + var tangent: Vector3 = (value - normal * value.dot(normal)).normalized() + if tangent.is_zero_approx(): + tangent = Vector3.UP.cross(normal).normalized() + if tangent.is_zero_approx(): + tangent = Vector3.RIGHT + return tangent diff --git a/drawing/surface_drawing_placement.gd.uid b/drawing/surface_drawing_placement.gd.uid new file mode 100644 index 0000000..9cf47f2 --- /dev/null +++ b/drawing/surface_drawing_placement.gd.uid @@ -0,0 +1 @@ +uid://dgb7stq5k85d0 diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd new file mode 100644 index 0000000..3add969 --- /dev/null +++ b/drawing/surface_drawing_protocol.gd @@ -0,0 +1,243 @@ +class_name SurfaceDrawingProtocol +extends RefCounted + +const CAPABILITY: StringName = &"surface_drawing_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const GRID_WIDTH: int = 32 +const GRID_HEIGHT: int = 32 +const CELL_SIZE: float = 0.075 +const MAX_ACTIVE_CANVASES: int = 24 +const MAX_CANVASES: int = 48 +const MAX_EDITS_PER_REQUEST: int = 16 +const MAX_CANVAS_ID_LENGTH: int = 64 +const MAX_REQUEST_ID_LENGTH: int = 64 +const MAX_STROKE_ID_LENGTH: int = 64 +const MAX_SESSION_ID_LENGTH: int = 96 +const MAX_COORDINATE: float = 10000.0 + + +static func validate_canvas_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + _valid_common(value) + and _valid_vector(value.get("origin")) + and _valid_vector(value.get("normal")) + and _valid_vector(value.get("tangent")) + and typeof(value.get("width")) == TYPE_INT + and int(value["width"]) == GRID_WIDTH + and typeof(value.get("height")) == TYPE_INT + and int(value["height"]) == GRID_HEIGHT + and typeof(value.get("cell_size")) in [TYPE_FLOAT, TYPE_INT] + and is_equal_approx(float(value["cell_size"]), CELL_SIZE) + ) + + +static func validate_edit_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + if ( + not _valid_common(value) + or typeof(value.get("canvas_id")) != TYPE_STRING + or str(value["canvas_id"]).is_empty() + or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH + or not _valid_stroke_id(value.get("stroke_id")) + or typeof(value.get("edits")) != TYPE_ARRAY + ): + return false + var edits: Array = value["edits"] + if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST: + return false + for edit_value: Variant in edits: + if not validate_cell_edit(edit_value): + return false + return true + + +static func validate_guide_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + _valid_common(value) + and _valid_canvas_id(value.get("canvas_id")) + and typeof(value.get("guide_visible")) == TYPE_BOOL + and typeof(value.get("finalized")) == TYPE_BOOL + ) + + +static func validate_undo_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return _valid_common(value) and _valid_stroke_id(value.get("stroke_id")) + + +static func validate_canvas_state(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + if ( + typeof(value.get("session_id")) != TYPE_STRING + or str(value["session_id"]).is_empty() + or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH + or typeof(value.get("canvas_id")) != TYPE_STRING + or str(value["canvas_id"]).is_empty() + or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH + or not _valid_vector(value.get("origin")) + or not _valid_vector(value.get("normal")) + or not _valid_vector(value.get("tangent")) + or typeof(value.get("width")) != TYPE_INT + or int(value["width"]) != GRID_WIDTH + or typeof(value.get("height")) != TYPE_INT + or int(value["height"]) != GRID_HEIGHT + or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT] + or not is_equal_approx(float(value["cell_size"]), CELL_SIZE) + or typeof(value.get("revision")) != TYPE_INT + or int(value["revision"]) < 0 + or typeof(value.get("guide_visible", true)) != TYPE_BOOL + or typeof(value.get("finalized", false)) != TYPE_BOOL + or typeof(value.get("layer", 0)) != TYPE_INT + or int(value.get("layer", 0)) < 0 + or not NetworkIdentityCrypto.valid_fingerprint( + value.get("creator_fingerprint") + ) + or typeof(value.get("cells")) != TYPE_ARRAY + ): + return false + var cells: Array = value["cells"] + if cells.size() > GRID_WIDTH * GRID_HEIGHT: + return false + for cell_value: Variant in cells: + if not validate_authoritative_cell(cell_value): + return false + return true + + +static func validate_guide_update(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + typeof(value.get("session_id")) == TYPE_STRING + and not str(value["session_id"]).is_empty() + and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH + and _valid_canvas_id(value.get("canvas_id")) + and typeof(value.get("revision")) == TYPE_INT + and int(value["revision"]) >= 1 + and typeof(value.get("guide_visible")) == TYPE_BOOL + and typeof(value.get("finalized")) == TYPE_BOOL + ) + + +static func validate_canvas_update(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + if ( + typeof(value.get("session_id")) != TYPE_STRING + or str(value["session_id"]).is_empty() + or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH + or typeof(value.get("canvas_id")) != TYPE_STRING + or str(value["canvas_id"]).is_empty() + or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH + or typeof(value.get("revision")) != TYPE_INT + or int(value["revision"]) < 1 + or typeof(value.get("edits")) != TYPE_ARRAY + ): + return false + var edits: Array = value["edits"] + if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST: + return false + for edit_value: Variant in edits: + if not validate_authoritative_cell(edit_value, true): + return false + return true + + +static func validate_cell_edit(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + if ( + typeof(value.get("x")) != TYPE_INT + or int(value["x"]) < 0 + or int(value["x"]) >= GRID_WIDTH + or typeof(value.get("y")) != TYPE_INT + or int(value["y"]) < 0 + or int(value["y"]) >= GRID_HEIGHT + or typeof(value.get("color_id")) not in [TYPE_STRING, TYPE_STRING_NAME] + ): + return false + var color_id := StringName(str(value["color_id"])) + return color_id.is_empty() or SurfaceDrawingPalette.has_color(color_id) + + +static func validate_authoritative_cell( + data: Variant, + allow_erased: bool = false, +) -> bool: + if not validate_cell_edit(data): + return false + var value: Dictionary = data + var color_id := StringName(str(value["color_id"])) + if color_id.is_empty(): + return allow_erased and str(value.get("author_fingerprint", "")).is_empty() + return NetworkIdentityCrypto.valid_fingerprint( + value.get("author_fingerprint") + ) + + +static func vector_to_array(value: Vector3) -> Array[float]: + return [value.x, value.y, value.z] + + +static func array_to_vector(value: Variant) -> Vector3: + if not _valid_vector(value): + return Vector3.ZERO + var array: Array = value + return Vector3(float(array[0]), float(array[1]), float(array[2])) + + +static func _valid_common(value: Dictionary) -> bool: + return ( + typeof(value.get("request_id")) == TYPE_STRING + and not str(value["request_id"]).is_empty() + and str(value["request_id"]).length() <= MAX_REQUEST_ID_LENGTH + and typeof(value.get("session_id")) == TYPE_STRING + and not str(value["session_id"]).is_empty() + and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH + ) + + +static func _valid_canvas_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_CANVAS_ID_LENGTH + ) + + +static func _valid_stroke_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_STROKE_ID_LENGTH + ) + + +static func _valid_vector(value: Variant) -> bool: + if typeof(value) != TYPE_ARRAY: + return false + var array: Array = value + if array.size() != 3: + return false + for component: Variant in array: + if typeof(component) not in [TYPE_FLOAT, TYPE_INT]: + return false + var number: float = float(component) + if not is_finite(number) or absf(number) > MAX_COORDINATE: + return false + return true diff --git a/drawing/surface_drawing_protocol.gd.uid b/drawing/surface_drawing_protocol.gd.uid new file mode 100644 index 0000000..9c45515 --- /dev/null +++ b/drawing/surface_drawing_protocol.gd.uid @@ -0,0 +1 @@ +uid://c258sivd8k8nc diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e00ff98..3a628d7 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -271,6 +271,15 @@ func can_open_fishing_shop() -> bool: ) +func can_use_surface_drawing() -> bool: + return ( + _gameplay_input_enabled + and not _external_input_blocked + and state == FishingState.READY + and _active_player == null + ) + + func is_ready_for_shop_transaction() -> bool: return ( _can_use_shop_gameplay() diff --git a/main/main.gd b/main/main.gd index 65ccc52..c4a5832 100644 --- a/main/main.gd +++ b/main/main.gd @@ -58,6 +58,9 @@ const NetworkItemUseServiceType = preload( const NetworkFishShowcaseServiceType = preload( "res://network/network_fish_showcase_service.gd" ) +const NetworkSurfaceDrawingServiceType = preload( + "res://network/network_surface_drawing_service.gd" +) const NetworkChatServiceType = preload( "res://network/network_chat_service.gd" ) @@ -130,6 +133,9 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _network_fish_showcase: NetworkFishShowcaseServiceType = ( %NetworkFishShowcaseService ) +@onready var _network_surface_drawing: NetworkSurfaceDrawingServiceType = ( + %NetworkSurfaceDrawingService +) @onready var _network_chat: NetworkChatServiceType = %NetworkChatService @onready var _network_mail: NetworkMailServiceType = %NetworkMailService @onready var _network_player_list: NetworkPlayerListService = %NetworkPlayerListService @@ -141,6 +147,7 @@ const SHOP_PATTERN_SCALE: float = 1.75 %NetworkProfileService ) @onready var _players_root: Node3D = $Players +@onready var _surface_drawings_root: Node3D = $SurfaceDrawings @onready var _title_background: ColorRect = %TitleBackground @onready var _player_menu_backdrop: ColorRect = %PlayerMenuBackdrop @onready var _shop_backdrop: ColorRect = %ShopBackdrop @@ -351,6 +358,16 @@ func _initialize_after_data_root() -> void: _player.inventory, _player.hotbar, ) + _network_surface_drawing.setup( + _network_session, + _player_spawn_service, + _relationships, + _player, + _surface_drawings_root, + ) + _network_player_list.set_surface_drawing_service( + _network_surface_drawing + ) _network_chat.setup(_network_session) _network_fishing.setup( _network_session, @@ -436,6 +453,7 @@ func _initialize_after_data_root() -> void: _network_profile_service, _network_player_list, _settings_manager, + _network_surface_drawing, ) _game_ui.setup_data_and_identity( _data_root, diff --git a/main/main.tscn b/main/main.tscn index ea69081..dd7c804 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -45,6 +45,7 @@ [ext_resource type="Texture2D" uid="uid://b6xws1d2dnbnn" path="res://art/patterns/pattern_moneyfish.png" id="43_shop_pattern"] [ext_resource type="PackedScene" uid="uid://w7n4gjqq1juc" path="res://art/exported/characters/base/netfishing_base_character.glb" id="44_4pcu1"] [ext_resource type="Script" path="res://network/network_fish_showcase_service.gd" id="45_fish_showcase"] +[ext_resource type="Script" path="res://network/network_surface_drawing_service.gd" id="46_surface_drawing"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -188,6 +189,10 @@ script = ExtResource("24_network_item") unique_name_in_owner = true script = ExtResource("45_fish_showcase") +[node name="NetworkSurfaceDrawingService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("46_surface_drawing") + [node name="NetworkChatService" type="Node" parent="." unique_id=1956959711] unique_name_in_owner = true script = ExtResource("25_network_chat") @@ -209,6 +214,8 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.0024132729, 0, -0.00093126 [node name="Players" type="Node3D" parent="." unique_id=550630144] +[node name="SurfaceDrawings" type="Node3D" parent="."] + [node name="Player" parent="Players" unique_id=485429332 instance=ExtResource("2_player")] unique_name_in_owner = true transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 16) diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index 9145cfa..6f65e12 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -11,6 +11,7 @@ var _known: KnownPlayerStore var _spawn: PlayerSpawnService var _chat: NetworkChatService var _mail: NetworkMailService +var _surface_drawing: NetworkSurfaceDrawingService var _revision := 0 var _host_block_pairs: Dictionary[String, bool] = {} var _peer_fingerprints: Dictionary[int, String] = {} @@ -96,6 +97,42 @@ func get_bans() -> Array[Dictionary]: return _bans.get_bans(_session.get_host_identity_fingerprint()) if _session.is_host() else [] +func set_surface_drawing_service( + service: NetworkSurfaceDrawingService, +) -> void: + _surface_drawing = service + if ( + _surface_drawing != null + and not _surface_drawing.session_artwork_changed.is_connected( + _on_session_artwork_changed + ) + ): + _surface_drawing.session_artwork_changed.connect( + _on_session_artwork_changed + ) + _changed() + + +func get_session_artwork_counts() -> Vector2i: + if _surface_drawing == null: + return Vector2i.ZERO + return Vector2i( + _surface_drawing.get_canvas_count(), + _surface_drawing.get_painted_cell_count(), + ) + + +func reset_session_artwork() -> bool: + if not _session.is_host() or _surface_drawing == null: + return false + var ok: bool = _surface_drawing.clear_session_artwork() + moderation_finished.emit( + ok, + "Session artwork cleared." if ok else "Session artwork could not be cleared.", + ) + return ok + + func set_muted(fingerprint: String, display_name: String, value: bool) -> bool: if fingerprint == _session.get_local_identity_fingerprint(): return false @@ -269,3 +306,10 @@ func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool: func _changed() -> void: _revision += 1 entries_changed.emit() + + +func _on_session_artwork_changed( + _canvas_count: int, + _painted_cell_count: int, +) -> void: + _changed() diff --git a/network/network_protocol.gd b/network/network_protocol.gd index f0892f7..e0f124c 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -10,7 +10,7 @@ const MAX_PUBLIC_KEY_LENGTH: int = 8192 const MAX_SIGNATURE_LENGTH: int = 2048 # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, -# 6 reliable shop transactions, 7 reliable item/equipment/showcase lifecycle, +# 6 reliable shop transactions, 7 reliable item/equipment/showcase/drawing, # 8 reliable ordered session chat, 9 reliable private session mail. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 @@ -18,6 +18,7 @@ const ITEM_RELIABLE_CHANNEL: int = 7 const CHAT_RELIABLE_CHANNEL: int = 8 const MAIL_RELIABLE_CHANNEL: int = 9 const ENET_CHANNEL_COUNT: int = 10 +const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v1" enum RejectionCode { NONE, @@ -83,7 +84,9 @@ static func make_client_hello( "local_profile_id": profile_id, "display_name": display_name, "client_nonce": client_nonce, - "capability_flags": PackedStringArray(), + "capability_flags": PackedStringArray([ + SURFACE_DRAWING_CAPABILITY, + ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, "identity_signature": identity_signature, @@ -122,6 +125,16 @@ static func validate_client_hello(data: Variant) -> String: TYPE_ARRAY, ]: return "Capabilities are invalid." + var capabilities: Variant = payload["capability_flags"] + if capabilities.size() > 32: + return "Capabilities are invalid." + for capability: Variant in capabilities: + if ( + typeof(capability) not in [TYPE_STRING, TYPE_STRING_NAME] + or str(capability).is_empty() + or str(capability).length() > 64 + ): + return "Capabilities are invalid." if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY: return "Cosmetic snapshot is invalid." if ( @@ -188,6 +201,7 @@ static func make_server_hello( "item_use_v1", "equipment_v1", "fish_showcase_v1", + SURFACE_DRAWING_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index d2a9477..4e5e389 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -168,6 +168,7 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: NetworkProtocol.PROTOCOL_VERSION, _player_identity.fingerprint, _player_identity.public_pem, + PackedStringArray([NetworkProtocol.SURFACE_DRAWING_CAPABILITY]), ) _registry.update_appearance(1, _local_appearance_snapshot) var host_profile_hello := NetworkProtocol.make_client_hello( @@ -361,6 +362,7 @@ func supports_server_capability(capability: StringName) -> bool: return str(capability) in PackedStringArray([ "movement_v1", "fishing_v1", "sale_v1", "shop_v1", "item_use_v1", "equipment_v1", "fish_showcase_v1", + NetworkProtocol.SURFACE_DRAWING_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -373,6 +375,14 @@ func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord: return _registry.get_peer(peer_id) +func peer_supports_capability( + peer_id: int, + capability: StringName, +) -> bool: + var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id) + return record != null and str(capability) in record.capability_flags + + func get_authenticated_peer_ids() -> Array[int]: return _registry.get_peer_ids() @@ -931,6 +941,7 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.PROTOCOL_VERSION, identity["fingerprint"], identity["public_key"], + _sanitized_capabilities(data.get("capability_flags", [])), ): _reject_peer( sender_id, @@ -1062,6 +1073,7 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.PROTOCOL_VERSION, _player_identity.fingerprint, _player_identity.public_pem, + PackedStringArray([NetworkProtocol.SURFACE_DRAWING_CAPABILITY]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) var local_record := _registry.get_peer(local_peer_id) @@ -1143,6 +1155,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void: NetworkProtocol.PROTOCOL_VERSION, str(entry.get("identity_fingerprint", "")), str(entry.get("identity_public_key", "")), + _sanitized_capabilities(entry.get("capability_flags", [])), ) var added_record := _registry.get_peer(peer_id) if added_record != null and added_record.identity_authenticated: @@ -1218,9 +1231,28 @@ func _make_spawn_entry( record.profile_authorization.duplicate(true) if record != null else {} ), + "capability_flags": ( + record.capability_flags.duplicate() + if record != null else PackedStringArray() + ), } +func _sanitized_capabilities(value: Variant) -> PackedStringArray: + var result := PackedStringArray() + if typeof(value) not in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]: + return result + for capability: Variant in value: + if ( + typeof(capability) in [TYPE_STRING, TYPE_STRING_NAME] + and not str(capability).is_empty() + and str(capability).length() <= 64 + and str(capability) not in result + ): + result.append(str(capability)) + return result + + func _verify_spawn_identity(entry: Dictionary) -> bool: var fingerprint := str(entry.get("identity_fingerprint", "")) var public_pem := NetworkIdentityCrypto.normalize_public_pem( diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd new file mode 100644 index 0000000..c080062 --- /dev/null +++ b/network/network_surface_drawing_service.gd @@ -0,0 +1,1731 @@ +class_name NetworkSurfaceDrawingService +extends Node + +const BrushHighlightShader: Shader = preload( + "res://drawing/surface_drawing_highlight.gdshader" +) + +signal hud_state_changed( + is_active: bool, + mode_name: String, + color_name: String, + color_value: Color, + brush_size: int, + 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 + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _relationships: PlayerRelationshipStore +var _local_player: Player +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 +var _color_ids: Array[StringName] = [] +var _color_index: int = 0 +var _painting: bool = false +var _erasing: bool = false +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] = {} + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + relationships: PlayerRelationshipStore, + local_player: Player, + drawing_root: Node3D, +) -> void: + _session = session + _spawn_service = spawn_service + _relationships = relationships + _local_player = local_player + _drawing_root = drawing_root + _color_ids = SurfaceDrawingPalette.get_color_ids() + 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 + ) + _create_brush_preview() + _create_placement_preview() + set_process(true) + _emit_hud_state("") + + +func handle_input(event: InputEvent, can_open: bool) -> bool: + if event is InputEventKey: + var key_event := event as InputEventKey + if ( + key_event.pressed + and not key_event.echo + and key_event.physical_keycode == KEY_P + ): + if _active: + deactivate() + return true + if can_open and _drawing_available(): + activate() + return true + return false + if not _active: + return false + if not can_open: + deactivate() + return false + if event.is_action_pressed("ui_cancel"): + if _placing_grid: + _set_placement_mode(false) + return true + deactivate() + return true + 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 + if ( + mouse_event.shift_pressed + and mouse_event.button_index in [ + MOUSE_BUTTON_WHEEL_UP, + MOUSE_BUTTON_WHEEL_DOWN, + ] + ): + return false + match mouse_event.button_index: + MOUSE_BUTTON_WHEEL_UP: + if mouse_event.pressed and not _placing_grid: + _set_brush_size(_brush_size + 1) + return true + MOUSE_BUTTON_WHEEL_DOWN: + if mouse_event.pressed and not _placing_grid: + _set_brush_size(_brush_size - 1) + return true + MOUSE_BUTTON_LEFT: + if _placing_grid: + if mouse_event.pressed: + if mouse_event.shift_pressed and mouse_event.ctrl_pressed: + _finalize_selected_guide() + elif mouse_event.shift_pressed: + _remove_selected_guide() + else: + _request_canvas_at_aim() + return true + if mouse_event.pressed: + _begin_stroke() + _painting = mouse_event.pressed and not mouse_event.shift_pressed + _erasing = mouse_event.pressed and mouse_event.shift_pressed + 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 + _pointer_screen_position = _clamped_pointer_position( + _pointer_screen_position + motion_event.screen_relative + ) + _update_aim() + return true + return false + + +func activate() -> void: + if _active or not _drawing_available(): + return + _active = true + _placing_grid = false + _reset_stroke() + _pointer_screen_position = get_viewport().get_visible_rect().size * 0.5 + _prior_mouse_mode = Input.mouse_mode + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + _update_aim() + _refresh_stencil_visibility() + _emit_hud_state( + "r place grid • click draw • shift erase • ctrl z undo • shift scroll zoom" + ) + + +func deactivate() -> void: + if not _active: + return + _active = false + _placing_grid = false + _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 + + +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("") + + +func request_canvas_at_surface( + origin: Vector3, + normal: Vector3, + tangent: Vector3, +) -> bool: + if not _drawing_available() or normal.is_zero_approx() or tangent.is_zero_approx(): + 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()), + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "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: + if not _drawing_available() or canvas_id.is_empty() or edits.is_empty(): + 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, + "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: + if not _drawing_available() or canvas_id.is_empty(): + 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: + if not _drawing_available() or _last_local_stroke_id.is_empty(): + _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 + _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 {} + var camera: Camera3D = _local_player.get_gameplay_camera() + 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, + ) + + +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 + if _overlaps_existing_canvas(origin, normal): + _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: + 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"])) + or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES + or _active_canvas_count() >= SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES + ): + 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 + ) + if _overlaps_existing_canvas(surface_hit["position"], normal): + 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), + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "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"])) + ): + 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"])) + ): + 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"]) + 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"]) + 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 + var cells: Dictionary[int, Dictionary] = _cells_by_key(state["cells"]) + 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"])) + ): + 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 + var cells: Dictionary[int, Dictionary] = _cells_by_key(state["cells"]) + for edit_value: Variant in data["edits"]: + var edit: Dictionary = edit_value + var key: int = int(edit["y"]) * int(state["width"]) + int(edit["x"]) + 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 = ( + _local_player.get_gameplay_camera() if _local_player != null else null + ) + 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 + ) + + +func _overlaps_existing_canvas(origin: Vector3, normal: Vector3) -> bool: + 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() + var width: float = float(state["width"]) * float(state["cell_size"]) + var height: float = float(state["height"]) * float(state["cell_size"]) + 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 + + +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: + if _drawing_root == null: + return + 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 = ( + float(SurfaceDrawingProtocol.GRID_WIDTH) + * SurfaceDrawingProtocol.CELL_SIZE + * 0.5 + ) + var half_height: float = ( + float(SurfaceDrawingProtocol.GRID_HEIGHT) + * SurfaceDrawingProtocol.CELL_SIZE + * 0.5 + ) + for x: int in range(SurfaceDrawingProtocol.GRID_WIDTH + 1): + 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)) + for y: int in range(SurfaceDrawingProtocol.GRID_HEIGHT + 1): + 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: + if not _active or _placing_grid == enabled: + 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" + ) + ) + + +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...") + + +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), + ) + + +func _cycle_color(direction: int) -> void: + if _color_ids.is_empty(): + return + _color_index = posmod(_color_index + direction, _color_ids.size()) + _reset_stroke() + _emit_hud_state("") + + +func _set_brush_size(value: int) -> void: + _brush_size = clampi(value, 1, 4) + _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, + 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] + + +func _cells_by_key(values: Array) -> Dictionary[int, Dictionary]: + var result: Dictionary[int, Dictionary] = {} + for value: Variant in values: + if typeof(value) != TYPE_DICTIONARY: + continue + var cell: Dictionary = value + var key: int = ( + int(cell.get("y", -1)) * SurfaceDrawingProtocol.GRID_WIDTH + + int(cell.get("x", -1)) + ) + result[key] = cell.duplicate(true) + return result + + +func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary: + var cells: Dictionary[int, Dictionary] = _cells_by_key(state.get("cells", [])) + 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) + + +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: + request_canvas_snapshot.rpc_id(1, _new_request_id("snapshot")) + return + 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() + + +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() + ) diff --git a/network/network_surface_drawing_service.gd.uid b/network/network_surface_drawing_service.gd.uid new file mode 100644 index 0000000..2f78f04 --- /dev/null +++ b/network/network_surface_drawing_service.gd.uid @@ -0,0 +1 @@ +uid://bkiepgu8j5r6t diff --git a/network/peer_registry.gd b/network/peer_registry.gd index c3881be..2cab061 100644 --- a/network/peer_registry.gd +++ b/network/peer_registry.gd @@ -16,6 +16,7 @@ class PeerRecord: var identity_public_key: String = "" var identity_authenticated: bool = false var profile_authorization: Dictionary = {} + var capability_flags: PackedStringArray = PackedStringArray() var _records: Dictionary[int, PeerRecord] = {} @@ -28,6 +29,7 @@ func add_peer( protocol_version: int, identity_fingerprint: String = "", identity_public_key: String = "", + capability_flags: PackedStringArray = PackedStringArray(), ) -> bool: if ( peer_id <= 0 @@ -49,6 +51,7 @@ func add_peer( record.identity_authenticated = NetworkIdentityCrypto.valid_fingerprint( identity_fingerprint ) + record.capability_flags = capability_flags.duplicate() _records[peer_id] = record return true diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd new file mode 100644 index 0000000..cdd850d --- /dev/null +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -0,0 +1,252 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18133 +const WAIT_MSEC: int = 20000 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Surface drawing multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_private_host(TEST_PORT)) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + await physics_frame + await physics_frame + + var service := main.get_node( + "%NetworkSurfaceDrawingService" + ) as NetworkSurfaceDrawingService + var surface: Dictionary = _surface_below_local_player(main) + assert(not surface.is_empty()) + assert(service.request_canvas_at_surface( + surface["position"], surface["normal"], Vector3.RIGHT + )) + var canvas_id: String = service.get_canvas_ids()[0] + assert(service.request_cell_edits(canvas_id, [{ + "x": 7, + "y": 7, + "color_id": "coral", + }])) + assert(session.set_host_open(true)) + + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var remote_record: PeerRegistry.PeerRecord = session.get_peer_record( + remote_peer_id + ) + assert(remote_record != null) + assert(session.peer_supports_capability( + remote_peer_id, SurfaceDrawingProtocol.CAPABILITY + )) + + var overwrite_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < overwrite_deadline: + await process_frame + var state: Dictionary = service.get_canvas_state(canvas_id) + if int(state.get("revision", 0)) < 2: + continue + var cell: Dictionary = _find_cell(state, 7, 7) + if ( + str(cell.get("color_id", "")) == "blue" + and str(cell.get("author_fingerprint", "")) + == remote_record.identity_fingerprint + ): + break + var overwritten_state: Dictionary = service.get_canvas_state(canvas_id) + assert(int(overwritten_state["revision"]) >= 2) + var overwritten_cell: Dictionary = _find_cell(overwritten_state, 7, 7) + assert(str(overwritten_cell["color_id"]) == "blue") + assert( + str(overwritten_cell["author_fingerprint"]) + == remote_record.identity_fingerprint + ) + + assert(service.request_cell_edits(canvas_id, [{ + "x": 8, + "y": 7, + "color_id": "sunny", + }])) + var undo_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < undo_deadline: + await process_frame + var state: Dictionary = service.get_canvas_state(canvas_id) + if str(_find_cell(state, 7, 7).get("color_id", "")) == "coral": + break + assert( + str(_find_cell(service.get_canvas_state(canvas_id), 7, 7)["color_id"]) + == "coral" + ) + var guide_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < guide_deadline: + await process_frame + if not bool(service.get_canvas_state(canvas_id).get("guide_visible", true)): + break + assert(not bool(service.get_canvas_state(canvas_id)["guide_visible"])) + # Keep the shared hidden state observable before publishing the restore. + await create_timer(1.0).timeout + assert(service.request_guide_visibility(canvas_id, true)) + await create_timer(1.0).timeout + assert(service.request_guide_visibility(canvas_id, false, true)) + await create_timer(1.0).timeout + assert(service.clear_session_artwork()) + await create_timer(1.0).timeout + print("Surface drawing multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call( + "_on_title_join_game_requested", + "127.0.0.1:%d" % TEST_PORT, + ) + var session := main.get_node("%NetworkSession") as NetworkSession + var join_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + assert(session.supports_server_capability( + SurfaceDrawingProtocol.CAPABILITY + )) + + var service := main.get_node( + "%NetworkSurfaceDrawingService" + ) as NetworkSurfaceDrawingService + var snapshot_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < snapshot_deadline + and service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().size() == 1) + var canvas_id: String = service.get_canvas_ids()[0] + var snapshot: Dictionary = service.get_canvas_state(canvas_id) + assert(str(_find_cell(snapshot, 7, 7).get("color_id", "")) == "coral") + assert(service.request_cell_edits(canvas_id, [{ + "x": 7, + "y": 7, + "color_id": "blue", + }])) + + var shared_edit_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < shared_edit_deadline: + await process_frame + var state: Dictionary = service.get_canvas_state(canvas_id) + if str(_find_cell(state, 8, 7).get("color_id", "")) == "sunny": + break + var final_state: Dictionary = service.get_canvas_state(canvas_id) + assert(str(_find_cell(final_state, 7, 7)["color_id"]) == "blue") + assert(str(_find_cell(final_state, 8, 7)["color_id"]) == "sunny") + assert(service.request_undo_last_stroke()) + var undo_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < undo_deadline: + await process_frame + var state: Dictionary = service.get_canvas_state(canvas_id) + if str(_find_cell(state, 7, 7).get("color_id", "")) == "coral": + break + assert( + str(_find_cell(service.get_canvas_state(canvas_id), 7, 7)["color_id"]) + == "coral" + ) + assert(service.request_guide_visibility(canvas_id, false)) + var guide_hidden_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < guide_hidden_deadline: + await process_frame + if not bool(service.get_canvas_state(canvas_id).get("guide_visible", true)): + break + assert(not bool(service.get_canvas_state(canvas_id)["guide_visible"])) + var guide_restored_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < guide_restored_deadline: + await process_frame + if bool(service.get_canvas_state(canvas_id).get("guide_visible", false)): + break + assert(bool(service.get_canvas_state(canvas_id)["guide_visible"])) + var finalized_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < finalized_deadline: + await process_frame + if bool(service.get_canvas_state(canvas_id).get("finalized", false)): + break + assert(bool(service.get_canvas_state(canvas_id)["finalized"])) + var reset_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < reset_deadline: + await process_frame + if service.get_canvas_ids().is_empty(): + break + assert(service.get_canvas_ids().is_empty()) + print("Surface drawing multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _surface_below_local_player(main: Node) -> Dictionary: + var player := main.get("_player") as Player + var query := PhysicsRayQueryParameters3D.create( + player.global_position + Vector3.UP * 3.0, + player.global_position + Vector3.DOWN * 6.0, + 1, + ) + query.collide_with_areas = false + query.collide_with_bodies = true + query.exclude = [player.get_rid()] + return player.get_world_3d().direct_space_state.intersect_ray(query) + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +func _find_cell(state: Dictionary, x: int, y: int) -> Dictionary: + for value: Variant in state.get("cells", []): + if typeof(value) != TYPE_DICTIONARY: + continue + var cell: Dictionary = value + if int(cell.get("x", -1)) == x and int(cell.get("y", -1)) == y: + return cell + return {} + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main diff --git a/tests/surface_drawing_multiplayer_validation.gd.uid b/tests/surface_drawing_multiplayer_validation.gd.uid new file mode 100644 index 0000000..cc43853 --- /dev/null +++ b/tests/surface_drawing_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://bj5dje2scya34 diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd new file mode 100644 index 0000000..dda1823 --- /dev/null +++ b/tests/surface_drawing_runtime_validation.gd @@ -0,0 +1,161 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18132 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_private_host(TEST_PORT)) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + await physics_frame + await physics_frame + + var player := main.get("_player") as Player + var service := main.get_node( + "%NetworkSurfaceDrawingService" + ) as NetworkSurfaceDrawingService + assert(service != null) + assert(session.supports_server_capability(SurfaceDrawingProtocol.CAPABILITY)) + _validate_marker_controls(service, player) + var query := PhysicsRayQueryParameters3D.create( + player.global_position + Vector3.UP * 3.0, + player.global_position + Vector3.DOWN * 6.0, + 1, + ) + query.collide_with_areas = false + query.collide_with_bodies = true + query.exclude = [player.get_rid()] + var hit: Dictionary = player.get_world_3d().direct_space_state.intersect_ray(query) + assert(not hit.is_empty()) + assert(service.request_canvas_at_surface( + hit["position"], hit["normal"], Vector3.RIGHT + )) + assert(service.get_canvas_ids().size() == 1) + var canvas_id: String = service.get_canvas_ids()[0] + assert(service.request_cell_edits(canvas_id, [{ + "x": 8, + "y": 8, + "color_id": "ocean_teal", + }])) + var state: Dictionary = service.get_canvas_state(canvas_id) + assert(int(state["revision"]) == 1) + assert((state["cells"] as Array).size() == 1) + assert(state["cells"][0]["color_id"] == "ocean_teal") + assert( + state["cells"][0]["author_fingerprint"] + == session.get_local_identity_fingerprint() + ) + assert(service.request_guide_visibility(canvas_id, false)) + state = service.get_canvas_state(canvas_id) + assert(not bool(state["guide_visible"])) + var canvas_nodes: Dictionary = service.get("_canvas_nodes") + var canvas := canvas_nodes[canvas_id] as SurfaceDrawingCanvas + assert(not canvas.is_guide_visible()) + assert(service.request_guide_visibility(canvas_id, true)) + assert(bool(service.get_canvas_state(canvas_id)["guide_visible"])) + assert(service.request_guide_visibility(canvas_id, false, true)) + assert(bool(service.get_canvas_state(canvas_id)["finalized"])) + assert(canvas.is_finalized()) + assert(service.request_canvas_at_surface( + hit["position"], hit["normal"], Vector3.RIGHT + )) + assert(service.get_canvas_ids().size() == 2) + var replacement_canvas_id: String = "" + for candidate_id: String in service.get_canvas_ids(): + if candidate_id != canvas_id: + replacement_canvas_id = candidate_id + assert(not replacement_canvas_id.is_empty()) + assert(service.request_cell_edits(replacement_canvas_id, [{ + "x": 8, + "y": 8, + "color_id": "blue", + }])) + assert((service.get_canvas_state(canvas_id)["cells"] as Array).is_empty()) + assert( + str(service.get_canvas_state(replacement_canvas_id)["cells"][0]["color_id"]) + == "blue" + ) + assert(service.request_undo_last_stroke()) + assert((service.get_canvas_state(replacement_canvas_id)["cells"] as Array).is_empty()) + assert( + str(service.get_canvas_state(canvas_id)["cells"][0]["color_id"]) + == "ocean_teal" + ) + var player_list := main.get_node( + "%NetworkPlayerListService" + ) as NetworkPlayerListService + assert(player_list.get_session_artwork_counts() == Vector2i(2, 1)) + assert(player_list.reset_session_artwork()) + assert(service.get_canvas_ids().is_empty()) + + print("Surface drawing runtime validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _validate_marker_controls( + service: NetworkSurfaceDrawingService, + player: Player, +) -> void: + var prior_mouse_mode: Input.MouseMode = Input.mouse_mode + service.activate() + assert(service.is_active()) + assert(not service.is_placement_mode()) + if DisplayServer.get_name() != "headless": + assert(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) + + var placement_key := InputEventKey.new() + placement_key.physical_keycode = KEY_R + placement_key.pressed = true + assert(service.handle_input(placement_key, true)) + assert(service.is_placement_mode()) + + var pointer_before: Vector2 = service.get_pointer_screen_position() + var pointer_motion := InputEventMouseMotion.new() + pointer_motion.screen_relative = Vector2(30.0, -12.0) + assert(service.handle_input(pointer_motion, true)) + assert(service.get_pointer_screen_position() != pointer_before) + var zoom_event := InputEventMouseButton.new() + zoom_event.button_index = MOUSE_BUTTON_WHEEL_UP + zoom_event.shift_pressed = true + zoom_event.pressed = true + assert(not service.handle_input(zoom_event, true)) + var prior_zoom: float = float(player.get("_target_zoom")) + player.call("_unhandled_input", zoom_event) + assert(float(player.get("_target_zoom")) < prior_zoom) + + var camera_press := InputEventMouseButton.new() + camera_press.button_index = MOUSE_BUTTON_RIGHT + camera_press.pressed = true + assert(not service.handle_input(camera_press, true)) + var pointer_before_camera: Vector2 = service.get_pointer_screen_position() + assert(not service.handle_input(pointer_motion, true)) + assert(service.get_pointer_screen_position() == pointer_before_camera) + var camera_release := InputEventMouseButton.new() + camera_release.button_index = MOUSE_BUTTON_RIGHT + camera_release.pressed = false + assert(not service.handle_input(camera_release, true)) + + assert(service.handle_input(placement_key, true)) + assert(not service.is_placement_mode()) + service.deactivate() + assert(Input.mouse_mode == prior_mouse_mode) diff --git a/tests/surface_drawing_runtime_validation.gd.uid b/tests/surface_drawing_runtime_validation.gd.uid new file mode 100644 index 0000000..af3cd35 --- /dev/null +++ b/tests/surface_drawing_runtime_validation.gd.uid @@ -0,0 +1 @@ +uid://8c30wxeu4lxv diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd new file mode 100644 index 0000000..25c1873 --- /dev/null +++ b/tests/surface_drawing_validation.gd @@ -0,0 +1,284 @@ +extends SceneTree + +const FINGERPRINT_A: String = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) +const FINGERPRINT_B: String = ( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_palette() + _validate_protocol_bounds() + _validate_grid_snapping() + await _validate_canvas_geometry_and_collaboration() + await _validate_blocked_author_visibility() + _validate_peer_capability_tracking() + print("Surface drawing validation: PASS") + quit() + + +func _validate_palette() -> void: + var ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() + assert(ids.size() == 8) + assert(ids.front() == SurfaceDrawingPalette.DEFAULT_COLOR_ID) + for color_id: StringName in ids: + assert(SurfaceDrawingPalette.has_color(color_id)) + assert(not SurfaceDrawingPalette.get_display_name(color_id).is_empty()) + assert( + SurfaceDrawingPalette.filter_unlocked_ids([]) + == [SurfaceDrawingPalette.DEFAULT_COLOR_ID] + ) + + +func _validate_protocol_bounds() -> void: + assert(SurfaceDrawingProtocol.GRID_WIDTH == 32) + assert(SurfaceDrawingProtocol.GRID_HEIGHT == 32) + assert(is_equal_approx(SurfaceDrawingProtocol.CELL_SIZE, 0.075)) + assert(is_equal_approx( + float(SurfaceDrawingProtocol.GRID_WIDTH) + * SurfaceDrawingProtocol.CELL_SIZE, + 2.4, + )) + var canvas_request: Dictionary = { + "request_id": "canvas-1", + "session_id": "session", + "origin": [0.0, 1.0, 2.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + } + assert(SurfaceDrawingProtocol.validate_canvas_request(canvas_request)) + var malformed: Dictionary = canvas_request.duplicate(true) + malformed["origin"] = [INF, 0.0, 0.0] + assert(not SurfaceDrawingProtocol.validate_canvas_request(malformed)) + var edit_request: Dictionary = { + "request_id": "edit-1", + "session_id": "session", + "canvas_id": "canvas-1", + "stroke_id": "stroke-1", + "edits": [{"x": 0, "y": 0, "color_id": "ocean_teal"}], + } + assert(SurfaceDrawingProtocol.validate_edit_request(edit_request)) + edit_request["edits"] = [{ + "x": SurfaceDrawingProtocol.GRID_WIDTH, + "y": 0, + "color_id": "ocean_teal", + }] + assert(not SurfaceDrawingProtocol.validate_edit_request(edit_request)) + var guide_request: Dictionary = { + "request_id": "guide-1", + "session_id": "session", + "canvas_id": "canvas-1", + "guide_visible": false, + "finalized": false, + } + assert(SurfaceDrawingProtocol.validate_guide_request(guide_request)) + guide_request["guide_visible"] = 0 + assert(not SurfaceDrawingProtocol.validate_guide_request(guide_request)) + var undo_request: Dictionary = { + "request_id": "undo-1", + "session_id": "session", + "stroke_id": "stroke-1", + } + assert(SurfaceDrawingProtocol.validate_undo_request(undo_request)) + + +func _validate_grid_snapping() -> void: + var anchor: Dictionary = { + "session_id": "session", + "canvas_id": "anchor", + "origin": [0.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 0, + "guide_visible": false, + "finalized": true, + "layer": 1, + "creator_fingerprint": FINGERPRINT_A, + "cells": [], + } + var snapped: Dictionary = SurfaceDrawingPlacement.resolve( + Vector3(2.28, 0.02, 0.08), + Vector3.UP, + Vector3.RIGHT, + [anchor], + ) + assert(bool(snapped["snapped"])) + var snapped_origin: Vector3 = snapped["origin"] + assert(snapped_origin.is_equal_approx(Vector3(2.4, 0.0, 0.0))) + var unsnapped: Dictionary = SurfaceDrawingPlacement.resolve( + Vector3(1.2, 0.0, 0.0), + Vector3.UP, + Vector3.RIGHT, + [anchor], + ) + assert(not bool(unsnapped["snapped"])) + + +func _validate_canvas_geometry_and_collaboration() -> void: + var world := Node3D.new() + root.add_child(world) + var canvas := SurfaceDrawingCanvas.new() + world.add_child(canvas) + var state: Dictionary = { + "session_id": "session", + "canvas_id": "canvas-1", + "origin": [0.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 0, + "creator_fingerprint": FINGERPRINT_A, + "cells": [], + } + assert(canvas.setup(state, null)) + var first_center: Vector3 = canvas.get_cell_world_position(0, 0) + assert(canvas.cell_at_world_point(first_center) == Vector2i(0, 0)) + assert(canvas.contains_world_point(first_center)) + var first_update: Dictionary = { + "session_id": "session", + "canvas_id": "canvas-1", + "revision": 1, + "edits": [{ + "x": 3, + "y": 4, + "color_id": "coral", + "author_fingerprint": FINGERPRINT_A, + }], + } + assert(canvas.apply_update(first_update)) + var finish_update: Dictionary = { + "session_id": "session", + "canvas_id": "canvas-1", + "revision": 2, + "guide_visible": false, + "finalized": true, + } + assert(canvas.apply_guide_update(finish_update)) + assert(not canvas.is_guide_visible()) + assert(canvas.is_finalized()) + await process_frame + var finished_pixel_scale: float = canvas.get_rendered_pixel_size() + assert( + is_equal_approx( + finished_pixel_scale, SurfaceDrawingProtocol.CELL_SIZE + ), + "finished pixel scale %f does not match cell size %f" + % [finished_pixel_scale, SurfaceDrawingProtocol.CELL_SIZE], + ) + var second_update: Dictionary = { + "session_id": "session", + "canvas_id": "canvas-1", + "revision": 3, + "edits": [{ + "x": 3, + "y": 4, + "color_id": "blue", + "author_fingerprint": FINGERPRINT_B, + }], + } + assert(canvas.apply_update(second_update)) + var cells: Array[Dictionary] = canvas.get_authoritative_cells() + assert(cells.size() == 1) + assert(cells[0]["color_id"] == "blue") + assert(cells[0]["author_fingerprint"] == FINGERPRINT_B) + var erase_update: Dictionary = { + "session_id": "session", + "canvas_id": "canvas-1", + "revision": 4, + "edits": [{ + "x": 3, + "y": 4, + "color_id": "", + "author_fingerprint": "", + }], + } + assert(canvas.apply_update(erase_update)) + assert(canvas.get_authoritative_cells().is_empty()) + world.queue_free() + await process_frame + + +func _validate_peer_capability_tracking() -> void: + var registry := PeerRegistry.new() + assert(registry.add_peer( + 1, + "profile", + "Voyager", + NetworkProtocol.PROTOCOL_VERSION, + FINGERPRINT_A, + "public key fixture", + PackedStringArray([str(SurfaceDrawingProtocol.CAPABILITY)]), + )) + var record: PeerRegistry.PeerRecord = registry.get_peer(1) + assert(record != null) + assert(str(SurfaceDrawingProtocol.CAPABILITY) in record.capability_flags) + + +func _validate_blocked_author_visibility() -> void: + var relationships := PlayerRelationshipStore.new() + relationships.set("_loaded", true) + relationships.set("_records", { + FINGERPRINT_B: {"blocked": true, "muted": true}, + }) + var world := Node3D.new() + root.add_child(world) + var canvas := SurfaceDrawingCanvas.new() + world.add_child(canvas) + var state: Dictionary = { + "session_id": "session", + "canvas_id": "blocked-author-canvas", + "origin": [0.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": SurfaceDrawingProtocol.GRID_WIDTH, + "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 1, + "creator_fingerprint": FINGERPRINT_A, + "cells": [ + { + "x": 1, + "y": 1, + "color_id": "coral", + "author_fingerprint": FINGERPRINT_A, + }, + { + "x": 2, + "y": 1, + "color_id": "blue", + "author_fingerprint": FINGERPRINT_B, + }, + ], + } + assert(canvas.setup(state, relationships)) + assert(_rendered_pixel_count(canvas) == 1) + relationships.set("_records", {}) + canvas.refresh_relationship_visibility() + await process_frame + assert(_rendered_pixel_count(canvas) == 2) + world.queue_free() + await process_frame + + +func _rendered_pixel_count(canvas: SurfaceDrawingCanvas) -> int: + var count: int = 0 + for child: Node in canvas.get_children(): + if child is MultiMeshInstance3D: + var instance := child as MultiMeshInstance3D + if instance.multimesh != null: + count += instance.multimesh.instance_count + return count diff --git a/tests/surface_drawing_validation.gd.uid b/tests/surface_drawing_validation.gd.uid new file mode 100644 index 0000000..72e0102 --- /dev/null +++ b/tests/surface_drawing_validation.gd.uid @@ -0,0 +1 @@ +uid://dui5ueufqooul diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 22d8351..210d9cd 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -65,6 +65,10 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _effect_status: Label = %EffectStatus @onready var _chat_ui: ChatUIType = %ChatUI @onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu +@onready var _marker_hud: PanelContainer = %MarkerHUD +@onready var _marker_swatch: ColorRect = %MarkerSwatch +@onready var _marker_summary: Label = %MarkerSummary +@onready var _marker_help: Label = %MarkerHelp @onready var _title_settings_panel: SettingsPanelType = ( $UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel ) @@ -84,6 +88,7 @@ var _player_menu_hotbar_visible: bool = false var _item_effects: PlayerItemEffectsType var _main_shop_buyer: FishBuyerProfileType var _shop_interaction: ShopInteractionType +var _surface_drawing: NetworkSurfaceDrawingService func _ready() -> void: @@ -136,6 +141,7 @@ func setup( network_profile_service: NetworkProfileService, network_player_list: NetworkPlayerListService, settings_manager: PlayerSettingsManagerType, + surface_drawing: NetworkSurfaceDrawingService, ) -> void: _player = player _fishing_spot = fishing_spot @@ -206,9 +212,30 @@ func setup( ) _main_shop_buyer = main_shop_buyer _shop_interaction = shop_interaction + _surface_drawing = surface_drawing + if _surface_drawing != null: + _surface_drawing.hud_state_changed.connect( + _on_surface_drawing_hud_state_changed + ) func _input(event: InputEvent) -> void: + var drawing_can_open: bool = ( + _gameplay_ui_enabled + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _chat_input_open + and not _showcase_active + and _fishing_spot != null + and _fishing_spot.can_use_surface_drawing() + ) + if ( + _surface_drawing != null + and _surface_drawing.handle_input(event, drawing_can_open) + ): + get_viewport().set_input_as_handled() + return if _emote_radial_menu == null: return var can_open: bool = ( @@ -306,6 +333,8 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_transient_hud.visible = enabled and not _player_menu_open _refresh_chat_availability() if not enabled: + if _surface_drawing != null: + _surface_drawing.deactivate() close_player_menu_for_session_end() _fishing_shop.close_for_session_end() _fishing_panel.visible = false @@ -320,6 +349,8 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: func set_system_menu_open(is_open: bool) -> void: _system_menu_open = is_open + if is_open and _surface_drawing != null: + _surface_drawing.deactivate() _refresh_chat_availability() _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( @@ -532,6 +563,8 @@ func _on_showcase_changed( func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open + if is_open and _surface_drawing != null: + _surface_drawing.deactivate() _gameplay_transient_hud.visible = _gameplay_ui_enabled and not is_open if is_open: _hotbar_ui.set_drag_enabled(false) @@ -577,6 +610,8 @@ func _on_player_menu_exit_started() -> void: func _on_shop_visibility_changed(is_open: bool) -> void: _shop_open = is_open + if is_open and _surface_drawing != null: + _surface_drawing.deactivate() shop_backdrop_visibility_changed.emit(is_open) if not is_open and _player_menu.is_shop_cooler_mounted(): _player_menu.unmount_shop_cooler() @@ -679,9 +714,40 @@ func _emit_interactive_pointer_ui_changed() -> void: func _on_chat_text_entry_ownership_changed(active: bool) -> void: _chat_input_open = active + if active and _surface_drawing != null: + _surface_drawing.deactivate() _emit_interactive_pointer_ui_changed() +func _on_surface_drawing_hud_state_changed( + is_active: bool, + mode_name: String, + color_name: String, + color_value: Color, + brush_size: int, + status: String, +) -> void: + _marker_hud.visible = is_active and _gameplay_ui_enabled + _marker_swatch.visible = mode_name != "place grid" + _marker_swatch.color = color_value + _marker_summary.text = ( + "place shared grid" + if mode_name == "place grid" + else "marker • %s • brush %d" % [ + color_name.to_lower(), brush_size, + ] + ) + _marker_help.text = ( + status + if not status.is_empty() + else ( + "click place/restore • shift hide • ctrl shift finish • shift scroll zoom" + if mode_name == "place grid" + else "click draw • shift click erase • ctrl z undo • shift scroll zoom" + ) + ) + + func _refresh_chat_availability() -> void: if _chat_ui == null: return diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 6213eb6..0d0a297 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -34,6 +34,17 @@ corner_radius_bottom_left = 12 [sub_resource type="StyleBoxFlat" id="StyleBox_transparent"] bg_color = Color(0, 0, 0, 0) +[sub_resource type="StyleBoxFlat" id="StyleBox_marker_panel"] +bg_color = Color(0.051, 0.173, 0.227, 0.96) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 +content_margin_left = 14.0 +content_margin_top = 9.0 +content_margin_right = 14.0 +content_margin_bottom = 9.0 + [node name="GameUI" type="CanvasLayer"] script = ExtResource("1_ui") @@ -254,6 +265,51 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) theme_override_constants/shadow_offset_x = 2 theme_override_constants/shadow_offset_y = 2 +[node name="MarkerHUD" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] +unique_name_in_owner = true +visible = false +z_index = 58 +anchors_preset = 10 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -510.0 +offset_top = 18.0 +offset_right = -18.0 +offset_bottom = 92.0 +grow_horizontal = 0 +mouse_filter = 2 +theme = ExtResource("3_theme") +theme_override_styles/panel = SubResource("StyleBox_marker_panel") + +[node name="Layout" type="HBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="MarkerSwatch" type="ColorRect" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(42, 42) +layout_mode = 2 +mouse_filter = 2 +color = Color(0.960784, 0.933333, 0.85098, 1) + +[node name="Text" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 0 + +[node name="MarkerSummary" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout/Text"] +unique_name_in_owner = true +layout_mode = 2 +text = "marker • chalk white • brush 1" +theme_override_font_sizes/font_size = 18 + +[node name="MarkerHelp" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout/Text"] +unique_name_in_owner = true +layout_mode = 2 +text = "click draw • shift erase • ctrl z undo • shift scroll zoom" +theme_override_colors/font_color = Color(0.623529, 0.811765, 0.823529, 1) +theme_override_font_sizes/font_size = 13 + [node name="ChatUI" type="Control" parent="UIRoot"] unique_name_in_owner = true layout_mode = 1 diff --git a/ui/players_page.gd b/ui/players_page.gd index 4a20ca4..d834176 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -105,6 +105,8 @@ func _refresh() -> void: func _build_active_rows() -> void: + if _service.is_local_host(): + _build_session_artwork_controls() var entries := _service.get_entries() if entries.is_empty(): _add_empty("No authenticated players.") @@ -169,6 +171,33 @@ func _build_active_rows() -> void: _list.add_child(row) +func _build_session_artwork_controls() -> void: + var counts: Vector2i = _service.get_session_artwork_counts() + var row := _make_row() + var label := Label.new() + label.custom_minimum_size.x = 830 + label.text = "session artwork · %d layers · %d painted pixels" % [ + counts.x, counts.y, + ] + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + row.add_child(label) + var reset := Button.new() + reset.text = "reset paint" + reset.disabled = counts.x == 0 + reset.tooltip_text = "Clears all shared artwork from this session." + reset.pressed.connect(func() -> void: + _confirm( + "Clear all shared paint from this session?\nThis cannot be undone.", + _service.reset_session_artwork, + ) + ) + UtilityPageStyle.apply_ocean_button(reset) + row.add_child(reset) + _list.add_child(row) + + func _build_relationship_rows() -> void: var records := _service.get_relationships() if records.is_empty():