diff --git a/drawing/surface_drawing_canvas.gd b/drawing/surface_drawing_canvas.gd index f6ceb94..01d34d7 100644 --- a/drawing/surface_drawing_canvas.gd +++ b/drawing/surface_drawing_canvas.gd @@ -2,7 +2,6 @@ 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 @@ -13,13 +12,17 @@ var grid_height: int = 0 var cell_size: float = 0.0 var revision: int = -1 var creator_fingerprint: String = "" +var participant_fingerprints: Array[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 _cell_surface_transforms: Array[Transform3D] = [] +var _pixel_image: Image +var _pixel_texture: ImageTexture +var _pixel_instance: MeshInstance3D var _grid_instance: MeshInstance3D var _guide_visible: bool = true var _finalized: bool = false @@ -42,6 +45,7 @@ func setup( cell_size = float(data["cell_size"]) revision = int(data["revision"]) creator_fingerprint = str(data["creator_fingerprint"]) + participant_fingerprints = _participants_from_state(data) _finalized = bool(data.get("finalized", false)) _guide_visible = bool(data.get("guide_visible", true)) and not _finalized _layer = int(data.get("layer", 0)) @@ -65,7 +69,7 @@ func setup( var cell: Dictionary = cell_value _cells[_cell_key(int(cell["x"]), int(cell["y"]))] = cell.duplicate(true) _build_grid() - _rebuild_pixels() + _build_pixel_renderer() return true @@ -83,8 +87,15 @@ func apply_update(data: Dictionary) -> bool: _cells.erase(key) else: _cells[key] = edit.duplicate(true) + participant_fingerprints = _participants_from_update( + data, participant_fingerprints + ) revision = int(data["revision"]) - _rebuild_pixels() + if is_hidden_by_relationship(): + _rebuild_pixel_image() + else: + _apply_pixel_edits(data["edits"]) + _refresh_grid_visibility() return true @@ -96,15 +107,32 @@ func apply_guide_update(data: Dictionary) -> bool: ): return false revision = int(data["revision"]) + participant_fingerprints = _participants_from_update( + data, participant_fingerprints + ) _finalized = bool(data["finalized"]) _guide_visible = bool(data["guide_visible"]) and not _finalized - _refresh_grid_visibility() - _rebuild_pixels() + if _finalized: + _destroy_grid() + else: + _refresh_grid_visibility() return true func refresh_relationship_visibility() -> void: - _rebuild_pixels() + _rebuild_pixel_image() + if _pixel_instance != null: + _pixel_instance.visible = not is_hidden_by_relationship() + _refresh_grid_visibility() + + +func is_hidden_by_relationship() -> bool: + if _relationships == null: + return false + for fingerprint: String in participant_fingerprints: + if _relationships.is_blocked(fingerprint): + return true + return false func set_stencil_visible(should_be_visible: bool) -> void: @@ -121,7 +149,27 @@ func is_finalized() -> bool: func get_rendered_pixel_size() -> float: - return cell_size * _pixel_fill() + return cell_size + + +func get_rendered_pixel_count() -> int: + if is_hidden_by_relationship(): + return 0 + var count: int = 0 + for cell: Dictionary in _cells.values(): + if not _visible_cell_color(cell).is_equal_approx(Color.TRANSPARENT): + count += 1 + return count + + +func get_export_image() -> Image: + if _pixel_image == null or _pixel_image.is_empty(): + return null + return _pixel_image.duplicate() as Image + + +func has_guide_geometry() -> bool: + return _grid_instance != null and is_instance_valid(_grid_instance) func contains_world_point(world_point: Vector3, tolerance: float = 0.3) -> bool: @@ -192,7 +240,18 @@ func get_cell_surface_transform( ) -> 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) + var key: int = _cell_key(x, y) + var local_transform: Transform3D = ( + _cell_surface_transforms[key] + if key >= 0 and key < _cell_surface_transforms.size() + else _sample_cell_transform(x, y, 1.0) + ) + var scaled_basis: Basis = local_transform.basis + var resolved_fill: float = clampf(fill, 0.05, 1.0) + scaled_basis.x *= resolved_fill + scaled_basis.y *= resolved_fill + local_transform.basis = scaled_basis + return global_transform * local_transform func get_authoritative_cells() -> Array[Dictionary]: @@ -210,6 +269,9 @@ func get_authoritative_cells() -> Array[Dictionary]: func _build_grid() -> void: if _grid_instance != null: _grid_instance.queue_free() + _grid_instance = null + if _finalized: + return var immediate := ImmediateMesh.new() var material := StandardMaterial3D.new() material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED @@ -219,6 +281,7 @@ func _build_grid() -> void: 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 + var sampled_vertices: Dictionary[Vector2i, Dictionary] = {} 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): @@ -229,11 +292,15 @@ func _build_grid() -> void: immediate, _surface_origin + _surface_tangent * horizontal + _surface_bitangent * start_vertical, + Vector2i(x, y_segment), + sampled_vertices, ) _add_grid_vertex( immediate, _surface_origin + _surface_tangent * horizontal + _surface_bitangent * (start_vertical + cell_size), + Vector2i(x, y_segment + 1), + sampled_vertices, ) for y: int in range(grid_height + 1): var vertical: float = -half_height + float(y) * cell_size @@ -245,12 +312,16 @@ func _build_grid() -> void: immediate, _surface_origin + _surface_tangent * start_horizontal + _surface_bitangent * vertical, + Vector2i(x_segment, y), + sampled_vertices, ) _add_grid_vertex( immediate, _surface_origin + _surface_tangent * (start_horizontal + cell_size) + _surface_bitangent * vertical, + Vector2i(x_segment + 1, y), + sampled_vertices, ) immediate.surface_end() _grid_instance = MeshInstance3D.new() @@ -261,8 +332,16 @@ func _build_grid() -> void: _refresh_grid_visibility() -func _add_grid_vertex(immediate: ImmediateMesh, world_point: Vector3) -> void: - var sampled: Dictionary = _sample_surface(world_point) +func _add_grid_vertex( + immediate: ImmediateMesh, + world_point: Vector3, + grid_point: Vector2i, + sampled_vertices: Dictionary[Vector2i, Dictionary], +) -> void: + var sampled: Dictionary = sampled_vertices.get(grid_point, {}) + if sampled.is_empty(): + sampled = _sample_surface(world_point) + sampled_vertices[grid_point] = sampled var point: Vector3 = sampled["position"] var normal: Vector3 = sampled["normal"] immediate.surface_add_vertex( @@ -270,56 +349,130 @@ func _add_grid_vertex(immediate: ImmediateMesh, world_point: Vector3) -> void: ) -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 +func _build_pixel_renderer() -> void: + if _pixel_instance != null: + _pixel_instance.queue_free() + _pixel_instance = null + var cell_count: int = grid_width * grid_height + var vertices := PackedVector3Array() + var texture_coordinates := PackedVector2Array() + var indices := PackedInt32Array() + vertices.resize(cell_count * 4) + texture_coordinates.resize(cell_count * 4) + indices.resize(cell_count * 6) + _cell_surface_transforms.clear() + _cell_surface_transforms.resize(cell_count) + for y: int in range(grid_height): + for x: int in range(grid_width): + var cell_index: int = _cell_key(x, y) + var surface_transform := _sample_cell_transform(x, y, 1.0) + _cell_surface_transforms[cell_index] = surface_transform + var center: Vector3 = surface_transform.origin + var horizontal: Vector3 = surface_transform.basis.x * 0.5 + var vertical: Vector3 = surface_transform.basis.y * 0.5 + var vertex_offset: int = cell_index * 4 + vertices[vertex_offset] = center - horizontal - vertical + vertices[vertex_offset + 1] = center + horizontal - vertical + vertices[vertex_offset + 2] = center + horizontal + vertical + vertices[vertex_offset + 3] = center - horizontal + vertical + var image_row: int = _image_y(y) + var u_min: float = float(x) / float(grid_width) + var u_max: float = float(x + 1) / float(grid_width) + var v_min: float = float(image_row) / float(grid_height) + var v_max: float = float(image_row + 1) / float(grid_height) + texture_coordinates[vertex_offset] = Vector2(u_min, v_max) + texture_coordinates[vertex_offset + 1] = Vector2(u_max, v_max) + texture_coordinates[vertex_offset + 2] = Vector2(u_max, v_min) + texture_coordinates[vertex_offset + 3] = Vector2(u_min, v_min) + var index_offset: int = cell_index * 6 + indices[index_offset] = vertex_offset + indices[index_offset + 1] = vertex_offset + 1 + indices[index_offset + 2] = vertex_offset + 2 + indices[index_offset + 3] = vertex_offset + indices[index_offset + 4] = vertex_offset + 2 + indices[index_offset + 5] = vertex_offset + 3 + _rebuild_pixel_image() + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_TEX_UV] = texture_coordinates + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) var material := StandardMaterial3D.new() material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - material.albedo_color = SurfaceDrawingPalette.get_color(color_id) + material.albedo_color = Color.WHITE + material.albedo_texture = _pixel_texture + material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + material.alpha_scissor_threshold = 0.5 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() - ), + mesh.surface_set_material(0, material) + _pixel_instance = MeshInstance3D.new() + _pixel_instance.name = "ArtworkTexture" + _pixel_instance.mesh = mesh + _pixel_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_pixel_instance) + _pixel_instance.visible = not is_hidden_by_relationship() + + +func _rebuild_pixel_image() -> void: + if ( + _pixel_image == null + or _pixel_image.get_width() != grid_width + or _pixel_image.get_height() != grid_height + ): + _pixel_image = Image.create( + grid_width, grid_height, false, Image.FORMAT_RGBA8 ) - 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 + _pixel_image.fill(Color.TRANSPARENT) + if not is_hidden_by_relationship(): + for cell: Dictionary in _cells.values(): + _set_image_cell(int(cell["x"]), int(cell["y"]), cell) + if _pixel_texture == null: + _pixel_texture = ImageTexture.create_from_image(_pixel_image) + else: + _pixel_texture.update(_pixel_image) + + +func _apply_pixel_edits(edits: Array) -> void: + if _pixel_image == null or _pixel_texture == null: + _rebuild_pixel_image() + return + for edit_value: Variant in edits: + var edit: Dictionary = edit_value + var x: int = int(edit["x"]) + var y: int = int(edit["y"]) + var cell: Dictionary = _cells.get(_cell_key(x, y), {}) + _set_image_cell(x, y, cell) + _pixel_texture.update(_pixel_image) + + +func _set_image_cell(x: int, y: int, cell: Dictionary) -> void: + if ( + _pixel_image == null + or x < 0 + or x >= grid_width + or y < 0 + or y >= grid_height + ): + return + _pixel_image.set_pixel(x, _image_y(y), _visible_cell_color(cell)) + + +func _visible_cell_color(cell: Dictionary) -> Color: + if cell.is_empty(): + return Color.TRANSPARENT + var color_id := StringName(str(cell.get("color_id", ""))) + return ( + SurfaceDrawingPalette.get_color(color_id) + if SurfaceDrawingPalette.has_color(color_id) + else Color.TRANSPARENT + ) + + +func _image_y(cell_y: int) -> int: + return grid_height - 1 - cell_y func _sample_cell_transform( @@ -376,17 +529,60 @@ func _sample_surface(expected: Vector3) -> Dictionary: 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 _destroy_grid() -> void: + if _grid_instance == null: + return + _grid_instance.queue_free() + _grid_instance = null + + func _refresh_grid_visibility() -> void: if _grid_instance != null: - _grid_instance.visible = _stencil_requested_visible and _guide_visible + _grid_instance.visible = ( + _stencil_requested_visible + and _guide_visible + and not is_hidden_by_relationship() + ) + + +func _participants_from_state(data: Dictionary) -> Array[String]: + var result: Array[String] = [] + _append_participant(result, str(data.get("creator_fingerprint", ""))) + for value: Variant in data.get("participant_fingerprints", []): + _append_participant(result, str(value)) + for cell_value: Variant in data.get("cells", []): + var cell: Dictionary = cell_value + _append_participant( + result, str(cell.get("author_fingerprint", "")) + ) + return result + + +func _participants_from_update( + data: Dictionary, + fallback: Array[String], +) -> Array[String]: + var result: Array[String] = fallback.duplicate() + for value: Variant in data.get("participant_fingerprints", []): + _append_participant(result, str(value)) + for edit_value: Variant in data.get("edits", []): + var edit: Dictionary = edit_value + _append_participant( + result, str(edit.get("author_fingerprint", "")) + ) + return result + + +func _append_participant(result: Array[String], fingerprint: String) -> void: + if ( + NetworkIdentityCrypto.valid_fingerprint(fingerprint) + and fingerprint not in result + ): + result.append(fingerprint) func _cell_key(x: int, y: int) -> int: diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd index 5ca4ae0..8fdb054 100644 --- a/drawing/surface_drawing_protocol.gd +++ b/drawing/surface_drawing_protocol.gd @@ -18,6 +18,9 @@ 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 +# Sessions support up to 128 simultaneous players. Keep enough bounded history +# for a complete room turnover while never accepting unattributed edits. +const MAX_PARTICIPANTS: int = 256 const MAX_COORDINATE: float = 10000.0 @@ -64,6 +67,38 @@ static func validate_edit_request(data: Variant) -> bool: return true +static func validate_stamp_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + if ( + not _valid_common(value) + 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"]) not in GRID_SIZES + or typeof(value.get("height")) != TYPE_INT + or int(value["height"]) != int(value["width"]) + 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("pixels")) != TYPE_PACKED_BYTE_ARRAY + ): + return false + var pixels: PackedByteArray = value["pixels"] + var expected_size: int = int(value["width"]) * int(value["height"]) + if pixels.size() != expected_size: + return false + var maximum_palette_index: int = SurfaceDrawingPalette.COLORS.size() + var painted_count: int = 0 + for palette_index: int in pixels: + if palette_index > maximum_palette_index: + return false + if palette_index > 0: + painted_count += 1 + return painted_count > 0 + + static func validate_guide_request(data: Variant) -> bool: if typeof(data) != TYPE_DICTIONARY: return false @@ -112,6 +147,9 @@ static func validate_canvas_state(data: Variant) -> bool: or not NetworkIdentityCrypto.valid_fingerprint( value.get("creator_fingerprint") ) + or not _valid_participant_fingerprints( + value.get("participant_fingerprints", []) + ) or typeof(value.get("cells")) != TYPE_ARRAY ): return false @@ -127,6 +165,13 @@ static func validate_canvas_state(data: Variant) -> bool: or int((cell_value as Dictionary)["y"]) >= grid_height ): return false + var participants: Array = value.get("participant_fingerprints", []) + if not participants.is_empty(): + if str(value["creator_fingerprint"]) not in participants: + return false + for cell_value: Variant in cells: + if str((cell_value as Dictionary)["author_fingerprint"]) not in participants: + return false return true @@ -143,6 +188,9 @@ static func validate_guide_update(data: Variant) -> bool: and int(value["revision"]) >= 1 and typeof(value.get("guide_visible")) == TYPE_BOOL and typeof(value.get("finalized")) == TYPE_BOOL + and _valid_participant_fingerprints( + value.get("participant_fingerprints", []) + ) ) @@ -160,6 +208,9 @@ static func validate_canvas_update(data: Variant) -> bool: or typeof(value.get("revision")) != TYPE_INT or int(value["revision"]) < 1 or typeof(value.get("edits")) != TYPE_ARRAY + or not _valid_participant_fingerprints( + value.get("participant_fingerprints", []) + ) ): return false var edits: Array = value["edits"] @@ -168,6 +219,18 @@ static func validate_canvas_update(data: Variant) -> bool: for edit_value: Variant in edits: if not validate_authoritative_cell(edit_value, true): return false + var participants: Array = value.get("participant_fingerprints", []) + if not participants.is_empty(): + for edit_value: Variant in edits: + var edit: Dictionary = edit_value + var author_fingerprint: String = str( + edit.get("author_fingerprint", "") + ) + if ( + not str(edit.get("color_id", "")).is_empty() + and author_fingerprint not in participants + ): + return false return true @@ -242,6 +305,26 @@ static func _valid_stroke_id(value: Variant) -> bool: ) +static func _valid_participant_fingerprints(value: Variant) -> bool: + if typeof(value) != TYPE_ARRAY: + return false + var fingerprints: Array = value + if fingerprints.size() > MAX_PARTICIPANTS: + return false + var seen: Dictionary[String, bool] = {} + for fingerprint_value: Variant in fingerprints: + if typeof(fingerprint_value) != TYPE_STRING: + return false + var fingerprint: String = str(fingerprint_value) + if ( + not NetworkIdentityCrypto.valid_fingerprint(fingerprint) + or seen.has(fingerprint) + ): + return false + seen[fingerprint] = true + return true + + static func _valid_vector(value: Variant) -> bool: if typeof(value) != TYPE_ARRAY: return false diff --git a/economy/art_shop_stock.gd b/economy/art_shop_stock.gd index ff84d09..ddd6f3c 100644 --- a/economy/art_shop_stock.gd +++ b/economy/art_shop_stock.gd @@ -24,6 +24,9 @@ const GRID_PRODUCTS: Array[StringName] = [ &"grid_64x", &"grid_128x", ] +const STAMP_PRODUCTS: Array[StringName] = [ + PlayerArtUnlocks.STAMP_PRODUCT_ID, +] static func get_price(product_id: StringName) -> int: @@ -44,6 +47,8 @@ static func get_display_name(product_id: StringName) -> String: var grid_size: int = PlayerArtUnlocks.grid_size_for_product(product_id) if grid_size > 0: return "%d×%d grid" % [grid_size, grid_size] + if product_id == PlayerArtUnlocks.STAMP_PRODUCT_ID: + return "Stamp tool" return "Unknown art supply" @@ -61,4 +66,6 @@ static func get_description(product_id: StringName) -> String: return "Unlocks the %d×%d grid in the Paint UI." % [ grid_size, grid_size, ] + if product_id == PlayerArtUnlocks.STAMP_PRODUCT_ID: + return "Unlocks saved artwork stamps in the Art Kit." return "" diff --git a/main/main.gd b/main/main.gd index fa8fc0b..5e3a097 100644 --- a/main/main.gd +++ b/main/main.gd @@ -729,6 +729,7 @@ func _initialize_application(dedicated: bool) -> void: _player.bag, _player.hotbar, _player.art_unlocks, + _data_root, ) _network_player_list.set_surface_drawing_service( _network_surface_drawing diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index 4742d14..2014665 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -81,6 +81,7 @@ func get_entries() -> Array[PlayerListEntry]: and (_session.is_host() or not entry.is_operator) ) entry.can_ban = entry.can_kick + entry.can_clear_art = entry.can_kick entry.can_manage_operator = ( _session.can_manage_operators() and peer_id != local_id @@ -189,6 +190,20 @@ func kick(peer_id: int, fingerprint: String, revision: int) -> bool: return true +func clear_art(peer_id: int, fingerprint: String, revision: int) -> bool: + if _session.is_host(): + return _clear_art_on_host( + peer_id, fingerprint, revision, true + ) + if not _session.is_local_operator(): + moderation_finished.emit( + false, "Only the host or an operator can clear player artwork." + ) + return false + request_clear_art.rpc_id(1, peer_id, fingerprint) + return true + + func ban( peer_id: int, fingerprint: String, @@ -257,6 +272,23 @@ func request_kick(peer_id: int, fingerprint: String) -> void: ) +@rpc("any_peer", "call_remote", "reliable", 0) +func request_clear_art(peer_id: int, fingerprint: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_operator_sender(sender_id): + return + var ok: bool = _clear_art_on_host( + peer_id, fingerprint, -1, false + ) + _send_moderation_result( + sender_id, + ok, + "Player artwork cleared." + if ok + else "Player artwork could not be cleared.", + ) + + @rpc("any_peer", "call_remote", "reliable", 0) func request_ban(peer_id: int, fingerprint: String) -> void: var sender_id: int = multiplayer.get_remote_sender_id() @@ -551,12 +583,54 @@ func _ban_on_host( if local_host_request: moderation_finished.emit(false, "Ban could not be saved.") return false + if _surface_drawing != null: + _surface_drawing.clear_artwork_by_fingerprint(fingerprint) var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint, true) if local_host_request: moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.") return true +func _clear_art_on_host( + peer_id: int, + fingerprint: String, + revision: int, + local_host_request: bool, +) -> bool: + if not _valid_moderation_target( + peer_id, + fingerprint, + revision, + local_host_request, + local_host_request, + ): + if local_host_request: + moderation_finished.emit( + false, "That player is no longer connected." + ) + return false + if _surface_drawing == null: + if local_host_request: + moderation_finished.emit( + false, "Player artwork could not be cleared." + ) + return false + var affected_layers: int = ( + _surface_drawing.clear_artwork_by_fingerprint(fingerprint) + ) + var ok: bool = affected_layers >= 0 + if local_host_request: + moderation_finished.emit( + ok, + "Player artwork cleared." + if affected_layers > 0 + else "No shared artwork from that player was found." + if ok + else "Player artwork could not be cleared.", + ) + return ok + + func _reset_session_artwork_on_host( emit_local_result: bool = true, ) -> bool: diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd index e42519c..10348aa 100644 --- a/network/network_surface_drawing_service.gd +++ b/network/network_surface_drawing_service.gd @@ -27,6 +27,9 @@ 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 +const ARTWORK_DIRECTORY_NAME: String = "artwork" +const STAMP_ALPHA_THRESHOLD: float = 0.5 +const STAMP_COLOR_TOLERANCE: float = 0.012 const INVALID_POINTER_SCREEN_POSITION := Vector2(-1.0, -1.0) enum GuideAction { @@ -43,17 +46,25 @@ var _local_player: Player var _bag: PlayerBag var _hotbar: PlayerHotbar var _art_unlocks: PlayerArtUnlocks +var _data_root: PlayerDataRoot var _drawing_root: Node3D var _canvas_states: Dictionary[String, Dictionary] = {} var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {} var _selected_canvas_id: String = "" +var _hovered_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 _stamp_preview: MeshInstance3D +var _stamp_preview_material: StandardMaterial3D var _active: bool = false var _placing_grid: bool = false +var _stamp_mode: bool = false +var _stamp_path: String = "" +var _stamp_pixels := PackedByteArray() +var _stamp_image: Image var _brush_size: int = 1 var _grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE var _color_ids: Array[StringName] = [] @@ -90,6 +101,7 @@ func setup( bag: PlayerBag, hotbar: PlayerHotbar, art_unlocks: PlayerArtUnlocks, + data_root: PlayerDataRoot = null, ) -> void: _session = session _spawn_service = spawn_service @@ -99,6 +111,7 @@ func setup( _bag = bag _hotbar = hotbar _art_unlocks = art_unlocks + _data_root = data_root _refresh_local_unlocks() if _session != null: _session.state_changed.connect(_on_session_state_changed) @@ -178,6 +191,8 @@ func handle_input( if mouse_event.pressed: if _armed_guide_action != GuideAction.NONE: _execute_armed_guide_action() + elif _stamp_mode: + _request_stamp_at_aim() elif mouse_event.shift_pressed and mouse_event.ctrl_pressed: _finalize_selected_guide() elif mouse_event.shift_pressed: @@ -221,6 +236,7 @@ func activate( return _active = true _placing_grid = false + _clear_stamp_mode() _eraser_mode = false _clear_armed_guide_action(false) _refresh_local_unlocks() @@ -244,11 +260,13 @@ func deactivate() -> void: return _active = false _placing_grid = false + _clear_stamp_mode() _eraser_mode = false _clear_armed_guide_action(false) _camera_look_active = false _reset_stroke() _selected_canvas_id = "" + _hovered_canvas_id = "" _aim_hit.clear() _hide_previews() _refresh_stencil_visibility() @@ -281,6 +299,7 @@ func set_color_id(color_id: StringName) -> bool: if color_index < 0: return false _clear_armed_guide_action(true) + _clear_stamp_mode() _eraser_mode = false _color_index = color_index _reset_stroke() @@ -292,6 +311,7 @@ func set_eraser_mode(enabled: bool) -> void: if not _active: return _clear_armed_guide_action(true) + _clear_stamp_mode() _eraser_mode = enabled if _eraser_mode and _placing_grid: _placing_grid = false @@ -318,6 +338,7 @@ func arm_guide_action(action: int) -> bool: if _armed_guide_action == GuideAction.NONE: _armed_return_placement_mode = _placing_grid _armed_return_eraser_mode = _eraser_mode + _clear_stamp_mode() _armed_guide_action = action _placing_grid = true _eraser_mode = false @@ -349,6 +370,7 @@ func set_grid_size(value: int) -> bool: return false if _grid_size == value: return true + _clear_stamp_mode() _grid_size = value _rebuild_placement_preview() _update_previews() @@ -476,6 +498,28 @@ func _peer_can_place_grid(peer_id: int, grid_size: int) -> bool: ) +func _peer_can_stamp(peer_id: int, request: Dictionary) -> bool: + var entitlement: Dictionary = _peer_entitlement(peer_id) + var unlock_mask: int = int(entitlement.get("unlock_mask", 0)) + if ( + not bool(entitlement.get("has_kit", false)) + or not _mask_owns_product( + unlock_mask, PlayerArtUnlocks.STAMP_PRODUCT_ID + ) + or not _mask_unlocks_grid(unlock_mask, int(request["width"])) + ): + return false + var palette_ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() + var pixels: PackedByteArray = request["pixels"] + for palette_index: int in pixels: + if palette_index <= 0: + continue + var color_id: StringName = palette_ids[palette_index - 1] + if not _mask_unlocks_color(unlock_mask, color_id): + return false + return true + + func _peer_can_edit(peer_id: int, request: Dictionary) -> bool: var entitlement: Dictionary = _peer_entitlement(peer_id) if not bool(entitlement.get("has_kit", false)): @@ -564,6 +608,42 @@ func request_canvas_at_surface( return true +func request_stamp_at_surface( + origin: Vector3, + normal: Vector3, + tangent: Vector3, +) -> bool: + if ( + not can_activate() + or not _stamp_mode + or _stamp_pixels.size() != _grid_size * _grid_size + or _art_unlocks == null + or not _art_unlocks.is_stamp_unlocked() + or not _art_unlocks.is_grid_size_unlocked(_grid_size) + or normal.is_zero_approx() + or tangent.is_zero_approx() + ): + return false + var data: Dictionary = { + "request_id": _new_request_id("stamp"), + "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": _grid_size, + "height": _grid_size, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "pixels": _stamp_pixels, + } + if not SurfaceDrawingProtocol.validate_stamp_request(data): + return false + if _session.is_host(): + _handle_stamp_request(_session.get_local_peer_id(), data) + else: + submit_stamp_request.rpc_id(1, data) + return true + + func request_cell_edits( canvas_id: String, edits: Array[Dictionary], @@ -670,6 +750,29 @@ func clear_session_artwork() -> bool: return true +func clear_artwork_by_fingerprint(fingerprint: String) -> int: + if ( + not _drawing_available() + or not _session.is_host() + or not NetworkIdentityCrypto.valid_fingerprint(fingerprint) + ): + return -1 + var affected_layers: int = _apply_artwork_fingerprint_clear(fingerprint) + 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_artwork_fingerprint_clear.rpc_id( + peer_id, + _session.get_session_id(), + fingerprint, + ) + return affected_layers + + func get_canvas_ids() -> Array[String]: var result: Array[String] = [] for canvas_id: String in _canvas_states: @@ -682,6 +785,212 @@ func get_canvas_state(canvas_id: String) -> Dictionary: return Dictionary(_canvas_states.get(canvas_id, {})).duplicate(true) +func is_stamp_mode() -> bool: + return _stamp_mode + + +func get_saved_stamp_entries() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var directory: String = _artwork_directory() + if directory.is_empty() or not DirAccess.dir_exists_absolute(directory): + return result + var file_names: PackedStringArray = DirAccess.get_files_at(directory) + file_names.sort() + file_names.reverse() + for file_name: String in file_names: + if file_name.get_extension().to_lower() != "png": + continue + var entry: Dictionary = _decode_stamp_file( + directory.path_join(file_name) + ) + if entry.is_empty(): + continue + entry["available"] = _stamp_entry_is_unlocked(entry) + result.append(entry) + if result.size() >= 64: + break + return result + + +func select_saved_stamp(path: String) -> bool: + if ( + not _active + or _art_unlocks == null + or not _art_unlocks.is_stamp_unlocked() + ): + _emit_hud_state("unlock the stamp tool in Art Supplies") + return false + var entry: Dictionary = _decode_stamp_file(path) + if entry.is_empty(): + _emit_hud_state("this artwork PNG cannot be used as a stamp") + return false + if not _stamp_entry_is_unlocked(entry): + _emit_hud_state("this stamp uses an art supply that is still locked") + return false + _clear_armed_guide_action(true) + _stamp_path = str(entry["path"]) + _stamp_pixels = entry["pixels"] + _stamp_image = entry["image"] + _stamp_mode = true + _placing_grid = true + _eraser_mode = false + _grid_size = int(entry["grid_size"]) + _reset_stroke() + _selected_canvas_id = "" + _rebuild_placement_preview() + _rebuild_stamp_preview() + _update_aim() + _refresh_stencil_visibility() + _emit_hud_state( + "stamp selected • click a solid surface to place finalized artwork" + ) + return true + + +func export_aimed_canvas() -> String: + _update_aim() + if _hovered_canvas_id.is_empty(): + _emit_hud_state("aim at artwork before exporting") + return "" + return export_canvas_png(_hovered_canvas_id) + + +func export_canvas_png(canvas_id: String) -> String: + var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) + if canvas == null or not is_instance_valid(canvas): + _emit_hud_state("artwork is no longer available") + return "" + if canvas.get_rendered_pixel_count() <= 0: + _emit_hud_state("paint something before exporting") + return "" + if _data_root == null or _data_root.root_path.is_empty(): + _emit_hud_state("choose a NETfishing data folder before exporting") + return "" + var export_directory: String = _artwork_directory() + if DirAccess.make_dir_recursive_absolute(export_directory) != OK: + _emit_hud_state("the artwork folder could not be created") + return "" + var image: Image = canvas.get_export_image() + if image == null or image.is_empty(): + _emit_hud_state("the artwork image could not be prepared") + return "" + var export_path: String = _next_artwork_export_path(export_directory) + if image.save_png(export_path) != OK: + _emit_hud_state("the artwork PNG could not be saved") + return "" + print("Art Kit artwork exported: ", export_path) + _emit_hud_state("artwork exported to the data folder • artwork") + return export_path + + +func _artwork_directory() -> String: + if _data_root == null or _data_root.root_path.is_empty(): + return "" + return _data_root.root_path.path_join(ARTWORK_DIRECTORY_NAME) + + +func _decode_stamp_file(path: String) -> Dictionary: + var directory: String = _artwork_directory() + if directory.is_empty(): + return {} + var normalized_directory: String = directory.simplify_path().trim_suffix( + "/" + ) + var normalized_path: String = path.simplify_path() + if not normalized_path.begins_with(normalized_directory + "/"): + return {} + if normalized_path.get_extension().to_lower() != "png": + return {} + var image: Image = Image.load_from_file(normalized_path) + if image == null or image.is_empty(): + return {} + if ( + image.get_width() != image.get_height() + or image.get_width() not in SurfaceDrawingProtocol.GRID_SIZES + ): + return {} + if image.is_compressed() and image.decompress() != OK: + return {} + image.convert(Image.FORMAT_RGBA8) + var grid_size: int = image.get_width() + var pixels := PackedByteArray() + pixels.resize(grid_size * grid_size) + var used_color_ids: Array[StringName] = [] + var painted_count: int = 0 + for cell_y: int in range(grid_size): + var image_y: int = grid_size - 1 - cell_y + for x: int in range(grid_size): + var color: Color = image.get_pixel(x, image_y) + var palette_index: int = 0 + if color.a >= STAMP_ALPHA_THRESHOLD: + palette_index = _stamp_palette_index(color) + if palette_index <= 0: + return {} + painted_count += 1 + var color_id: StringName = SurfaceDrawingPalette.get_color_ids()[ + palette_index - 1 + ] + if color_id not in used_color_ids: + used_color_ids.append(color_id) + pixels[cell_y * grid_size + x] = palette_index + if painted_count <= 0: + return {} + return { + "path": normalized_path, + "file_name": normalized_path.get_file(), + "grid_size": grid_size, + "painted_count": painted_count, + "used_color_ids": used_color_ids, + "pixels": pixels, + "image": image, + } + + +func _stamp_palette_index(color: Color) -> int: + var palette_ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() + for index: int in range(palette_ids.size()): + var palette_color: Color = SurfaceDrawingPalette.get_color( + palette_ids[index] + ) + var difference := Vector3( + color.r - palette_color.r, + color.g - palette_color.g, + color.b - palette_color.b, + ) + if difference.length() <= STAMP_COLOR_TOLERANCE: + return index + 1 + return 0 + + +func _stamp_entry_is_unlocked(entry: Dictionary) -> bool: + if _art_unlocks == null or not _art_unlocks.is_stamp_unlocked(): + return false + if not _art_unlocks.is_grid_size_unlocked(int(entry.get("grid_size", 0))): + return false + for color_id: StringName in entry.get("used_color_ids", []): + if not _art_unlocks.is_color_unlocked(color_id): + return false + return true + + +func _next_artwork_export_path(directory: String) -> String: + var date_time: Dictionary = Time.get_datetime_dict_from_system() + var stem: String = "netfishing-artwork-%04d%02d%02d-%02d%02d%02d" % [ + int(date_time.get("year", 0)), + int(date_time.get("month", 0)), + int(date_time.get("day", 0)), + int(date_time.get("hour", 0)), + int(date_time.get("minute", 0)), + int(date_time.get("second", 0)), + ] + var candidate: String = directory.path_join(stem + ".png") + var suffix: int = 2 + while FileAccess.file_exists(candidate): + candidate = directory.path_join("%s-%d.png" % [stem, suffix]) + suffix += 1 + return candidate + + func _process(_delta: float) -> void: if not _active: return @@ -714,26 +1023,53 @@ func _update_aim() -> void: _aim_hit = _raycast_from_pointer() var previous_canvas_id: String = _selected_canvas_id var found_canvas_id: String = "" + var found_hovered_canvas_id: String = "" var nearest_plane_distance: float = INF + var nearest_hovered_plane_distance: float = INF + var selected_layer: int = -1 + var hovered_layer: int = -1 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(): + if canvas.is_hidden_by_relationship(): continue var plane_distance: float = canvas.get_surface_plane_distance( hit_position ) + if not canvas.contains_world_point( + hit_position, + SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05, + ): + continue + var state: Dictionary = _canvas_states.get(canvas_id, {}) + var layer: int = int(state.get("layer", 0)) if ( - canvas.contains_world_point( - hit_position, - SurfaceDrawingCanvas.SURFACE_SAMPLE_DEPTH + 0.05, + plane_distance < nearest_hovered_plane_distance + or ( + is_equal_approx( + plane_distance, nearest_hovered_plane_distance + ) + and layer > hovered_layer + ) + ): + found_hovered_canvas_id = canvas_id + nearest_hovered_plane_distance = plane_distance + hovered_layer = layer + if canvas.is_finalized(): + continue + if ( + plane_distance < nearest_plane_distance + or ( + is_equal_approx(plane_distance, nearest_plane_distance) + and layer > selected_layer ) - and plane_distance < nearest_plane_distance ): found_canvas_id = canvas_id nearest_plane_distance = plane_distance + selected_layer = layer _selected_canvas_id = found_canvas_id + _hovered_canvas_id = found_hovered_canvas_id if previous_canvas_id != _selected_canvas_id: _reset_stroke() _update_previews() @@ -771,6 +1107,8 @@ func _update_previews() -> void: return if _placement_preview != null: _placement_preview.hide() + if _stamp_preview != null: + _stamp_preview.hide() _update_brush_preview() @@ -778,6 +1116,8 @@ func _update_placement_preview() -> void: if _placement_preview == null or _aim_hit.is_empty(): if _placement_preview != null: _placement_preview.hide() + if _stamp_preview != null: + _stamp_preview.hide() return var placement: Dictionary = _resolved_placement() var normal: Vector3 = placement["normal"] @@ -788,6 +1128,11 @@ func _update_placement_preview() -> void: Basis(tangent, bitangent, normal), hit_position + normal * GRID_PREVIEW_SURFACE_OFFSET, ) + if _stamp_preview != null: + _stamp_preview.global_transform = Transform3D( + Basis(tangent, bitangent, normal), + hit_position + normal * (GRID_PREVIEW_SURFACE_OFFSET + 0.001), + ) if _placement_preview_material != null: _placement_preview_material.albedo_color = ( Color(0.34, 1.0, 0.72, 0.88) @@ -795,6 +1140,8 @@ func _update_placement_preview() -> void: else Color(0.46, 0.91, 0.95, 0.72) ) _placement_preview.show() + if _stamp_preview != null: + _stamp_preview.visible = _stamp_mode func _update_brush_preview() -> void: @@ -841,6 +1188,8 @@ func _hide_previews() -> void: _hide_brush_preview() if _placement_preview != null: _placement_preview.hide() + if _stamp_preview != null: + _stamp_preview.hide() func _resolved_placement() -> Dictionary: @@ -889,6 +1238,20 @@ func _request_canvas_at_aim() -> void: ) +func _request_stamp_at_aim() -> void: + if _aim_hit.is_empty(): + _emit_hud_state("aim at a solid surface before placing a stamp") + return + var placement: Dictionary = _resolved_placement() + var normal: Vector3 = placement["normal"] + var origin: Vector3 = placement["origin"] + if _overlaps_existing_canvas(origin, normal, _grid_size): + _emit_hud_state("an active shared grid already covers this area") + return + if request_stamp_at_surface(origin, normal, placement["tangent"]): + _emit_hud_state("placing finalized stamp...") + + @rpc( "any_peer", "call_remote", @@ -969,6 +1332,7 @@ func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: "finalized": false, "layer": _canvas_sequence, "creator_fingerprint": record.identity_fingerprint, + "participant_fingerprints": [record.identity_fingerprint], "cells": [], } _apply_canvas_state(state) @@ -976,6 +1340,117 @@ func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: _emit_hud_state("shared grid placed • everyone can draw here") +@rpc( + "any_peer", + "call_remote", + "reliable", + SurfaceDrawingProtocol.RELIABLE_CHANNEL, +) +func submit_stamp_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_stamp_request(sender_id, data) + + +func _handle_stamp_request(peer_id: int, data: Dictionary) -> void: + var requested_size: int = int(data.get("width", 0)) + if ( + not _session.is_host() + or not SurfaceDrawingProtocol.validate_stamp_request(data) + or str(data["session_id"]) != _session.get_session_id() + or not _accept_request(peer_id, str(data["request_id"])) + or not _peer_can_stamp(peer_id, data) + or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES + or _allocated_grid_cells() + requested_size * requested_size + > SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS + ): + 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, requested_size + ): + 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) + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + if record == null or not record.identity_authenticated: + return + _canvas_sequence += 1 + var canvas_id: String = "%s-%d" % [ + _session.get_session_id().left(16), _canvas_sequence, + ] + 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": requested_size, + "height": requested_size, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 1, + "guide_visible": false, + "finalized": true, + "layer": _canvas_sequence, + "creator_fingerprint": record.identity_fingerprint, + "participant_fingerprints": [record.identity_fingerprint], + "cells": _stamp_cells( + data["pixels"], requested_size, record.identity_fingerprint + ), + "stamp": true, + } + _apply_canvas_state(state) + _broadcast_canvas_state(state) + _emit_hud_state("stamp placed • finalized artwork shared with everyone") + + +func _stamp_cells( + pixels: PackedByteArray, + grid_size: int, + author_fingerprint: String, +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var palette_ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() + for cell_key: int in range(pixels.size()): + var palette_index: int = pixels[cell_key] + if palette_index <= 0: + continue + var cell_y: int = floori(float(cell_key) / float(grid_size)) + result.append({ + "x": cell_key % grid_size, + "y": cell_y, + "color_id": str(palette_ids[palette_index - 1]), + "author_fingerprint": author_fingerprint, + }) + return result + + @rpc( "any_peer", "call_remote", @@ -1000,7 +1475,13 @@ func _handle_guide_request(peer_id: int, data: Dictionary) -> void: 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: + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + if ( + state.is_empty() + or avatar == null + or record == null + or not record.identity_authenticated + ): return var origin: Vector3 = SurfaceDrawingProtocol.array_to_vector(state["origin"]) if avatar.global_position.distance_to(origin) > MAX_DRAW_DISTANCE + 2.0: @@ -1015,6 +1496,11 @@ func _handle_guide_request(peer_id: int, data: Dictionary) -> void: and was_finalized == finalized ): return + if not _register_canvas_participant( + canvas_id, record.identity_fingerprint + ): + return + state = _canvas_states.get(canvas_id, {}) state["revision"] = int(state["revision"]) + 1 state["guide_visible"] = guide_visible state["finalized"] = finalized @@ -1025,6 +1511,9 @@ func _handle_guide_request(peer_id: int, data: Dictionary) -> void: "revision": int(state["revision"]), "guide_visible": guide_visible, "finalized": finalized, + "participant_fingerprints": state.get( + "participant_fingerprints", [] + ), } var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if canvas != null: @@ -1205,6 +1694,15 @@ func _queue_cell_mutation( var next: Dictionary = _cell_value_from_edit(edit) if _same_cell_value(current, next): return + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + if ( + record == null + or not record.identity_authenticated + or not _register_canvas_participant( + canvas_id, record.identity_fingerprint + ) + ): + return _record_stroke_change( peer_id, stroke_id, canvas_id, x, y, current, next ) @@ -1346,12 +1844,18 @@ func _publish_cell_mutations( continue state["revision"] = int(state["revision"]) + 1 state["cells"] = _sorted_cells(cells) + state["participant_fingerprints"] = _merged_participants( + state, {"edits": edits} + ) _canvas_states[canvas_id] = state var update: Dictionary = { "session_id": _session.get_session_id(), "canvas_id": canvas_id, "revision": int(state["revision"]), "edits": edits, + "participant_fingerprints": state.get( + "participant_fingerprints", [] + ), } var local_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if local_canvas != null: @@ -1489,6 +1993,23 @@ func receive_session_artwork_reset(session_id: String) -> void: _clear_session_artwork_state() +@rpc( + "authority", + "call_remote", + "reliable", + SurfaceDrawingProtocol.RELIABLE_CHANNEL, +) +func receive_artwork_fingerprint_clear( + session_id: String, + fingerprint: String, +) -> void: + if ( + session_id == _session.get_session_id() + and NetworkIdentityCrypto.valid_fingerprint(fingerprint) + ): + _apply_artwork_fingerprint_clear(fingerprint) + + @rpc( "any_peer", "call_remote", @@ -1525,7 +2046,28 @@ func _apply_canvas_state(data: Dictionary) -> void: and int(data["revision"]) <= int(previous_state["revision"]) ): return - _canvas_states[canvas_id] = data.duplicate(true) + var normalized_state: Dictionary = data.duplicate(true) + normalized_state["participant_fingerprints"] = _state_participants( + normalized_state + ) + _canvas_states[canvas_id] = normalized_state + _rebuild_canvas_node(normalized_state) + _refresh_stencil_visibility() + _emit_artwork_changed() + if ( + _active + and str(normalized_state.get("creator_fingerprint", "")) + == _session.get_local_identity_fingerprint() + ): + _emit_hud_state( + "stamp placed • move and click to place another" + if bool(normalized_state.get("stamp", false)) + else "shared grid placed • move and click to place another" + ) + + +func _rebuild_canvas_node(data: Dictionary) -> void: + var canvas_id: String = str(data["canvas_id"]) var previous_canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if previous_canvas != null: previous_canvas.queue_free() @@ -1536,14 +2078,6 @@ func _apply_canvas_state(data: Dictionary) -> void: 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: @@ -1575,6 +2109,9 @@ func _apply_canvas_update(data: Dictionary) -> void: cells[key] = edit.duplicate(true) state["revision"] = int(data["revision"]) state["cells"] = _sorted_cells(cells) + state["participant_fingerprints"] = _merged_participants( + state, data + ) _canvas_states[canvas_id] = state canvas.apply_update(data) _emit_artwork_changed() @@ -1600,6 +2137,9 @@ func _apply_guide_update(data: Dictionary) -> void: state["guide_visible"] = ( bool(data["guide_visible"]) and not bool(data["finalized"]) ) + state["participant_fingerprints"] = _merged_participants( + state, data + ) _canvas_states[canvas_id] = state canvas.apply_guide_update(data) if _active: @@ -1834,10 +2374,46 @@ func _rebuild_placement_preview() -> void: _drawing_root.add_child(_placement_preview) +func _rebuild_stamp_preview() -> void: + if _stamp_preview != null: + _stamp_preview.queue_free() + _stamp_preview = null + _stamp_preview_material = null + if ( + _drawing_root == null + or not _stamp_mode + or _stamp_image == null + or _stamp_image.is_empty() + ): + return + var quad := QuadMesh.new() + quad.size = Vector2.ONE * ( + float(_grid_size) * SurfaceDrawingProtocol.CELL_SIZE + ) + _stamp_preview_material = StandardMaterial3D.new() + _stamp_preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _stamp_preview_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _stamp_preview_material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + _stamp_preview_material.cull_mode = BaseMaterial3D.CULL_DISABLED + _stamp_preview_material.albedo_color = Color(1.0, 1.0, 1.0, 0.72) + _stamp_preview_material.albedo_texture = ImageTexture.create_from_image( + _stamp_image + ) + quad.material = _stamp_preview_material + _stamp_preview = MeshInstance3D.new() + _stamp_preview.name = "ArtworkStampPlacementPreview" + _stamp_preview.mesh = quad + _stamp_preview.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _stamp_preview.hide() + _drawing_root.add_child(_stamp_preview) + + func _set_placement_mode(enabled: bool) -> void: if not _active: return _clear_armed_guide_action(true) + if _stamp_mode: + _clear_stamp_mode() if _placing_grid == enabled: _emit_hud_state("") return @@ -1855,6 +2431,17 @@ func _set_placement_mode(enabled: bool) -> void: ) +func _clear_stamp_mode() -> void: + _stamp_mode = false + _stamp_path = "" + _stamp_pixels = PackedByteArray() + _stamp_image = null + if _stamp_preview != null: + _stamp_preview.queue_free() + _stamp_preview = null + _stamp_preview_material = null + + func _execute_armed_guide_action() -> void: var action: int = _armed_guide_action match action: @@ -2013,7 +2600,11 @@ func _current_color() -> Color: func _emit_hud_state(status: String) -> void: hud_state_changed.emit( _active, - "place grid" if _placing_grid else "marker", + ( + "stamp" + if _stamp_mode + else "place grid" if _placing_grid else "marker" + ), SurfaceDrawingPalette.get_display_name(_current_color_id()), _current_color(), _brush_size, @@ -2074,6 +2665,68 @@ func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary: ).duplicate(true) +func _register_canvas_participant( + canvas_id: String, + fingerprint: String, +) -> bool: + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + return false + var state: Dictionary = _canvas_states.get(canvas_id, {}) + if state.is_empty(): + return false + var participants: Array[String] = _state_participants(state) + if fingerprint in participants: + return true + if participants.size() >= SurfaceDrawingProtocol.MAX_PARTICIPANTS: + return false + participants.append(fingerprint) + state["participant_fingerprints"] = participants + _canvas_states[canvas_id] = state + return true + + +func _merged_participants( + state: Dictionary, + update: Dictionary, +) -> Array[String]: + var result: Array[String] = _state_participants(state) + for value: Variant in update.get("participant_fingerprints", []): + _append_participant(result, str(value)) + for edit_value: Variant in update.get("edits", []): + var edit: Dictionary = edit_value + _append_participant( + result, str(edit.get("author_fingerprint", "")) + ) + return result + + +func _state_participants(state: Dictionary) -> Array[String]: + var result: Array[String] = [] + _append_participant( + result, str(state.get("creator_fingerprint", "")) + ) + for value: Variant in state.get("participant_fingerprints", []): + _append_participant(result, str(value)) + for cell_value: Variant in state.get("cells", []): + var cell: Dictionary = cell_value + _append_participant( + result, str(cell.get("author_fingerprint", "")) + ) + return result + + +func _append_participant( + participants: Array[String], + fingerprint: String, +) -> void: + if ( + NetworkIdentityCrypto.valid_fingerprint(fingerprint) + and fingerprint not in participants + and participants.size() < SurfaceDrawingProtocol.MAX_PARTICIPANTS + ): + participants.append(fingerprint) + + func _cell_value_from_edit(edit: Dictionary) -> Dictionary: if edit.is_empty() or str(edit.get("color_id", "")).is_empty(): return {} @@ -2211,6 +2864,7 @@ func _clear_session_artwork_state() -> void: _canvas_nodes.clear() _canvas_sequence = 0 _selected_canvas_id = "" + _hovered_canvas_id = "" _stroke_history_by_peer.clear() _cell_last_stroke.clear() _last_local_stroke_id = "" @@ -2218,6 +2872,36 @@ func _clear_session_artwork_state() -> void: _emit_artwork_changed() +func _apply_artwork_fingerprint_clear(fingerprint: String) -> int: + var affected_canvas_ids: Array[String] = [] + for canvas_id: String in _canvas_states: + var state: Dictionary = _canvas_states[canvas_id] + if fingerprint in _state_participants(state): + affected_canvas_ids.append(canvas_id) + if affected_canvas_ids.is_empty(): + return 0 + for canvas_id: String in affected_canvas_ids: + _remove_canvas_state(canvas_id) + _stroke_history_by_peer.clear() + _cell_last_stroke.clear() + _last_local_stroke_id = "" + _hide_previews() + _emit_artwork_changed() + return affected_canvas_ids.size() + + +func _remove_canvas_state(canvas_id: String) -> void: + _canvas_states.erase(canvas_id) + var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) + if canvas != null: + canvas.queue_free() + _canvas_nodes.erase(canvas_id) + if _selected_canvas_id == canvas_id: + _selected_canvas_id = "" + if _hovered_canvas_id == canvas_id: + _hovered_canvas_id = "" + + func _emit_artwork_changed() -> void: session_artwork_changed.emit( get_canvas_count(), get_painted_cell_count() diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd index f401da6..5aa9643 100644 --- a/network/player_list_entry.gd +++ b/network/player_list_entry.gd @@ -14,5 +14,6 @@ var muted := false var blocked := false var can_kick := false var can_ban := false +var can_clear_art := false var can_manage_operator := false var revision := 0 diff --git a/progression/player_art_unlocks.gd b/progression/player_art_unlocks.gd index c6a6e57..0bc7acc 100644 --- a/progression/player_art_unlocks.gd +++ b/progression/player_art_unlocks.gd @@ -7,6 +7,7 @@ signal unlocks_changed(unlock_mask: int) const BASE_BRUSH_SIZE: int = 1 const BASE_GRID_SIZE: int = 16 +const STAMP_PRODUCT_ID: StringName = &"stamp_tool" const BRUSH_SIZES: Array[int] = [1, 2, 3, 4] const GRID_SIZES: Array[int] = [16, 32, 64, 128] @@ -25,6 +26,7 @@ const PRODUCT_BITS: Dictionary[StringName, int] = { &"grid_32x": 10, &"grid_64x": 11, &"grid_128x": 12, + STAMP_PRODUCT_ID: 13, } const COLOR_PRODUCTS: Dictionary[StringName, StringName] = { &"marker_ocean_teal": &"ocean_teal", @@ -45,7 +47,7 @@ const GRID_PRODUCTS: Dictionary[StringName, int] = { &"grid_64x": 64, &"grid_128x": 128, } -const ALL_UNLOCK_MASK: int = (1 << 13) - 1 +const ALL_UNLOCK_MASK: int = (1 << 14) - 1 var _unlock_mask: int = 0 @@ -146,6 +148,10 @@ func is_grid_size_unlocked(grid_size: int) -> bool: return grid_size in get_unlocked_grid_sizes() +func is_stamp_unlocked() -> bool: + return owns_product(STAMP_PRODUCT_ID) + + static func get_product_bit(product_id: StringName) -> int: return int(PRODUCT_BITS.get(product_id, -1)) diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 8e083b9..64fbfc3 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -44,6 +44,7 @@ readonly -a QUICK_TESTS=( "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" + "tests/surface_drawing_texture_renderer_validation.gd" "tests/tackle_order_validation.gd" "tests/terrain_biome_validation.gd" "tests/terrain_blender_material_validation.gd" diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index a4528c4..44ec053 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -481,6 +481,8 @@ func _run() -> void: var hide_button := toolbar.get_node("%HideGuideButton") as Button var restore_button := toolbar.get_node("%RestoreGuideButton") as Button var finalize_button := toolbar.get_node("%FinalizeGuideButton") as Button + var export_button := toolbar.get_node("%ExportButton") as Button + var stamp_button := toolbar.get_node("%StampButton") as Button assert( mode_button != null and eraser_button != null @@ -488,7 +490,13 @@ func _run() -> void: and hide_button != null and restore_button != null and finalize_button != null + and export_button != null + and stamp_button != null ) + assert(export_button.text == "png") + assert(export_button.tooltip_text == "export aimed artwork as PNG") + assert(stamp_button.text == "stamp") + assert(stamp_button.disabled) assert(toolbar.get_node_or_null("%CloseButton") == null) var expected_toolbar_icons: Dictionary[Button, String] = { mode_button: "/art/art_kit_marker.png", @@ -511,6 +519,8 @@ func _run() -> void: hide_button, restore_button, finalize_button, + export_button, + stamp_button, ]: assert(icon_button.custom_minimum_size == Vector2(48, 44)) assert(icon_button.get_theme_constant("icon_max_width") == 40) @@ -521,7 +531,9 @@ func _run() -> void: assert(is_equal_approx(icon_style.content_margin_top, 2.0)) assert(mode_button.custom_minimum_size == Vector2(48, 48)) assert(mode_button.get_theme_constant("icon_max_width") == 40) - assert(mode_button.get_index() > finalize_button.get_index()) + assert(export_button.get_index() > finalize_button.get_index()) + assert(stamp_button.get_index() > export_button.get_index()) + assert(mode_button.get_index() > stamp_button.get_index()) assert(hide_button.button_group != null) assert(hide_button.button_group == restore_button.button_group) assert(hide_button.button_group == finalize_button.button_group) @@ -537,6 +549,8 @@ func _run() -> void: hide_button, restore_button, finalize_button, + export_button, + stamp_button, ]: assert(pointer_only_control.focus_mode == Control.FOCUS_NONE) assert(brush_option.get_popup().unfocusable) @@ -639,13 +653,31 @@ func _run() -> void: )) == 24) for product_id: StringName in [ - &"marker_ocean_teal", &"brush_4x", &"grid_128x", + &"marker_ocean_teal", + &"brush_4x", + &"grid_128x", + PlayerArtUnlocks.STAMP_PRODUCT_ID, ]: assert(player.art_unlocks.unlock_product(product_id)) await process_frame assert(not (color_buttons[&"ocean_teal"] as Button).disabled) assert(not brush_option.get_popup().is_item_disabled(3)) assert(not grid_option.get_popup().is_item_disabled(3)) + assert(not stamp_button.disabled) + var stamp_library_panel := toolbar.get_node( + "%StampLibraryPanel" + ) as PanelContainer + var stamp_library_close := toolbar.get_node( + "%StampLibraryClose" + ) as Button + assert(stamp_library_panel != null and not stamp_library_panel.visible) + stamp_button.pressed.emit() + assert(stamp_library_panel.visible) + var library_pointer := InputEventMouseMotion.new() + library_pointer.position = stamp_library_panel.get_global_rect().get_center() + assert(toolbar.owns_pointer_event(library_pointer)) + stamp_library_close.pressed.emit() + assert(not stamp_library_panel.visible) assert(not locked_color_lock.visible) assert(str(brush_option.get_item_icon(3).get_meta( &"channel_mask_source", "" diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 884ad9b..9a53975 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -487,7 +487,7 @@ func _test_host_art_shop_purchase( for _frame: int in 4: await physics_frame var required_balance: int = ( - ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE * 3 + ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE * 4 ) if player.wallet.get_balance() < required_balance: assert(player.wallet.credit(required_balance - player.wallet.get_balance())) @@ -505,7 +505,10 @@ func _test_host_art_shop_purchase( assert(art_item.icon.resource_path.ends_with("/art/art_kit.png")) assert(player.hotbar.assign_item(0, ArtShopStock.ART_KIT_ITEM_ID)) for product_id: StringName in [ - &"marker_ocean_teal", &"brush_2x", &"grid_32x", + &"marker_ocean_teal", + &"brush_2x", + &"grid_32x", + PlayerArtUnlocks.STAMP_PRODUCT_ID, ]: _shop_result.clear() assert(not shop_service.request_art_upgrade(product_id).is_empty()) @@ -844,7 +847,9 @@ func _test_fishing_shop_sale_ui( for child: Node in shop.get_node("%SuppliesList").get_children(): if child is Label: stock_sections.append((child as Label).text) - assert(stock_sections == ["art kit", "markers", "brushes", "grids"]) + assert(stock_sections == [ + "art kit", "markers", "brushes", "grids", "stamps", + ]) var marker_icons := shop.find_children( "MarkerIcon", "TextureRect", true, false ) @@ -903,6 +908,17 @@ func _test_fishing_shop_sale_ui( ) ) assert(found_art_upgrade_icons == expected_art_upgrade_icons.size()) + var stamp_button: Button + for upgrade_node: Node in shop.find_children("*", "Button", true, false): + var upgrade_button := upgrade_node as Button + if StringName(str(upgrade_button.get_meta(&"art_product_id", ""))) == ( + PlayerArtUnlocks.STAMP_PRODUCT_ID + ): + stamp_button = upgrade_button + break + assert(stamp_button != null) + assert(stamp_button.text == "stamp") + assert(stamp_button.icon == null) var price_bubbles := shop.find_children( "PriceBubble", "PanelContainer", true, false ) diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd index 3712fd4..059ced1 100644 --- a/tests/operator_multiplayer_validation.gd +++ b/tests/operator_multiplayer_validation.gd @@ -65,6 +65,7 @@ func _run_host() -> void: assert(players_page != null) players_page.call("_refresh") assert(_has_button_text(players_page, "deop")) + assert(_has_button_text(players_page, "clear art")) var unban_deadline: int = Time.get_ticks_msec() + 12000 while ( diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index 74c86d2..ebba510 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -2,7 +2,7 @@ extends SceneTree const MainScene: PackedScene = preload("res://main/main.tscn") const TEST_PORT: int = 18133 -const WAIT_MSEC: int = 20000 +const WAIT_MSEC: int = 60000 func _initialize() -> void: @@ -83,6 +83,10 @@ func _run_host() -> void: str(overwritten_cell["author_fingerprint"]) == remote_record.identity_fingerprint ) + assert( + remote_record.identity_fingerprint + in overwritten_state.get("participant_fingerprints", []) + ) assert(service.request_cell_edits(canvas_id, [{ "x": 8, @@ -112,7 +116,61 @@ func _run_host() -> void: assert(service.request_guide_visibility(canvas_id, false, true)) await create_timer(1.0).timeout assert(service.clear_session_artwork()) + var stamp_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < stamp_deadline + and service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().size() == 1) + var stamp_id: String = service.get_canvas_ids()[0] + var stamp_state: Dictionary = service.get_canvas_state(stamp_id) + assert(bool(stamp_state.get("stamp", false))) + assert(bool(stamp_state["finalized"])) + assert((stamp_state["cells"] as Array).size() == 1) + assert(str(stamp_state["cells"][0]["color_id"]) == "coral") + assert( + stamp_state.get("participant_fingerprints", []) + == [remote_record.identity_fingerprint] + ) + # Give the client a full observation window before the authoritative clear. await create_timer(1.0).timeout + assert( + service.clear_artwork_by_fingerprint( + remote_record.identity_fingerprint + ) == 1 + ) + assert(service.get_canvas_ids().is_empty()) + var second_stamp_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < second_stamp_deadline + and service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().size() == 1) + var player_list := main.get_node( + "%NetworkPlayerListService" + ) as NetworkPlayerListService + var remote_entry: PlayerListEntry = null + for entry: PlayerListEntry in player_list.get_entries(): + if entry.peer_id == remote_peer_id: + remote_entry = entry + break + assert(remote_entry != null and remote_entry.can_ban) + assert(player_list.ban( + remote_peer_id, + remote_record.identity_fingerprint, + remote_record.display_name, + remote_entry.revision, + )) + assert(service.get_canvas_ids().is_empty()) + var removed_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < removed_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) print("Surface drawing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() @@ -210,8 +268,63 @@ func _run_client() -> void: if service.get_canvas_ids().is_empty(): break assert(service.get_canvas_ids().is_empty()) + var data_root := main.get("_data_root") as PlayerDataRoot + var stamp_directory: String = data_root.root_path.path_join("artwork") + assert(DirAccess.make_dir_recursive_absolute(stamp_directory) == OK) + var stamp_path: String = stamp_directory.path_join("multiplayer-stamp.png") + var stamp_image := Image.create(16, 16, false, Image.FORMAT_RGBA8) + stamp_image.fill(Color.TRANSPARENT) + stamp_image.set_pixel(3, 11, SurfaceDrawingPalette.get_color(&"coral")) + assert(stamp_image.save_png(stamp_path) == OK) + assert(service.select_saved_stamp(stamp_path)) + var stamp_surface: Dictionary = _surface_below_local_player(main) + assert(not stamp_surface.is_empty()) + assert(service.request_stamp_at_surface( + stamp_surface["position"], stamp_surface["normal"], Vector3.RIGHT + )) + var shared_stamp_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < shared_stamp_deadline + and service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().size() == 1) + var stamp_state: Dictionary = service.get_canvas_state( + service.get_canvas_ids()[0] + ) + assert(bool(stamp_state.get("stamp", false))) + assert(bool(stamp_state["finalized"])) + assert(str(stamp_state["cells"][0]["color_id"]) == "coral") + assert( + session.get_local_identity_fingerprint() + in stamp_state.get("participant_fingerprints", []) + ) + var clear_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < clear_deadline + and not service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().is_empty()) + assert(service.request_stamp_at_surface( + stamp_surface["position"], stamp_surface["normal"], Vector3.RIGHT + )) + var second_stamp_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < second_stamp_deadline + and service.get_canvas_ids().is_empty() + ): + await process_frame + assert(service.get_canvas_ids().size() == 1) + var ban_deadline: int = Time.get_ticks_msec() + WAIT_MSEC + while ( + Time.get_ticks_msec() < ban_deadline + and session.state != NetworkSession.State.SERVER_LOST + ): + await process_frame + assert(session.state == NetworkSession.State.SERVER_LOST) + assert(service.get_canvas_ids().is_empty()) print("Surface drawing multiplayer client validation: PASS") - session.disconnect_session("") main.queue_free() for _frame: int in 4: await process_frame diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index 8835127..6fa7a9e 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -67,6 +67,16 @@ func _run() -> void: state["cells"][0]["author_fingerprint"] == session.get_local_identity_fingerprint() ) + var export_path: String = service.export_canvas_png(canvas_id) + assert(not export_path.is_empty()) + var data_root := main.get("_data_root") as PlayerDataRoot + assert(export_path.begins_with(data_root.root_path.path_join("artwork"))) + assert(FileAccess.file_exists(export_path)) + var exported_image: Image = Image.load_from_file(export_path) + assert(exported_image.get_size() == Vector2i(16, 16)) + assert(exported_image.get_pixel(8, 7).is_equal_approx( + SurfaceDrawingPalette.get_color(&"ocean_teal") + )) assert(service.request_guide_visibility(canvas_id, false)) state = service.get_canvas_state(canvas_id) assert(not bool(state["guide_visible"])) @@ -78,6 +88,7 @@ func _run() -> void: assert(service.request_guide_visibility(canvas_id, false, true)) assert(bool(service.get_canvas_state(canvas_id)["finalized"])) assert(canvas.is_finalized()) + assert(not canvas.has_guide_geometry()) assert(service.request_canvas_at_surface( hit["position"], hit["normal"], Vector3.RIGHT )) @@ -109,6 +120,38 @@ func _run() -> void: assert(player_list.get_session_artwork_counts() == Vector2i(2, 1)) assert(player_list.reset_session_artwork()) assert(service.get_canvas_ids().is_empty()) + var stamp_entries: Array[Dictionary] = service.get_saved_stamp_entries() + assert(stamp_entries.size() == 1) + assert(str(stamp_entries[0]["path"]) == export_path) + assert(int(stamp_entries[0]["grid_size"]) == 16) + assert(bool(stamp_entries[0]["available"])) + service.activate(Vector2(640.0, 360.0)) + assert(service.select_saved_stamp(export_path)) + assert(service.is_stamp_mode()) + assert(service.request_stamp_at_surface( + hit["position"], hit["normal"], Vector3.RIGHT + )) + assert(service.get_canvas_ids().size() == 1) + var stamp_id: String = service.get_canvas_ids()[0] + var stamp_state: Dictionary = service.get_canvas_state(stamp_id) + assert(bool(stamp_state["stamp"])) + assert(bool(stamp_state["finalized"])) + assert(not bool(stamp_state["guide_visible"])) + assert((stamp_state["cells"] as Array).size() == 1) + assert(int(stamp_state["cells"][0]["x"]) == 8) + assert(int(stamp_state["cells"][0]["y"]) == 8) + assert(str(stamp_state["cells"][0]["color_id"]) == "ocean_teal") + assert( + stamp_state.get("participant_fingerprints", []) + == [session.get_local_identity_fingerprint()] + ) + var stamp_canvas_nodes: Dictionary = service.get("_canvas_nodes") + var stamp_canvas := stamp_canvas_nodes[stamp_id] as SurfaceDrawingCanvas + assert(stamp_canvas.is_finalized()) + assert(not stamp_canvas.has_guide_geometry()) + service.set_placement_mode(false) + assert(not service.is_stamp_mode()) + assert(service.clear_session_artwork()) print("Surface drawing runtime validation: PASS") session.disconnect_session("") diff --git a/tests/surface_drawing_texture_renderer_validation.gd b/tests/surface_drawing_texture_renderer_validation.gd new file mode 100644 index 0000000..ed99383 --- /dev/null +++ b/tests/surface_drawing_texture_renderer_validation.gd @@ -0,0 +1,101 @@ +extends SceneTree + +const CANVAS_COUNT: int = 5 +const GRID_SIZE: int = 32 +const FINGERPRINT: String = ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := Node3D.new() + root.add_child(world) + var color_ids: Array[StringName] = SurfaceDrawingPalette.get_color_ids() + var cells: Array[Dictionary] = [] + for y: int in range(GRID_SIZE): + for x: int in range(GRID_SIZE): + cells.append({ + "x": x, + "y": y, + "color_id": str(color_ids[(x + y) % color_ids.size()]), + "author_fingerprint": FINGERPRINT, + }) + var canvases: Array[SurfaceDrawingCanvas] = [] + var persistent_meshes: Array[Mesh] = [] + var persistent_textures: Array[Texture2D] = [] + for canvas_index: int in range(CANVAS_COUNT): + var canvas := SurfaceDrawingCanvas.new() + world.add_child(canvas) + assert(canvas.setup({ + "session_id": "texture-renderer-session", + "canvas_id": "canvas-%d" % canvas_index, + "origin": [float(canvas_index) * 3.0, 0.0, 0.0], + "normal": [0.0, 1.0, 0.0], + "tangent": [1.0, 0.0, 0.0], + "width": GRID_SIZE, + "height": GRID_SIZE, + "cell_size": SurfaceDrawingProtocol.CELL_SIZE, + "revision": 0, + "guide_visible": true, + "finalized": false, + "layer": canvas_index, + "creator_fingerprint": FINGERPRINT, + "cells": cells, + }, null)) + assert(canvas.get_rendered_pixel_count() == GRID_SIZE * GRID_SIZE) + var pixel_instance := canvas.get("_pixel_instance") as MeshInstance3D + assert(pixel_instance != null) + assert(pixel_instance.mesh.get_surface_count() == 1) + var material := pixel_instance.mesh.surface_get_material( + 0 + ) as StandardMaterial3D + assert(material != null) + assert(material.albedo_texture is ImageTexture) + canvases.append(canvas) + persistent_meshes.append(pixel_instance.mesh) + persistent_textures.append(material.albedo_texture) + for canvas_index: int in range(CANVAS_COUNT): + var canvas: SurfaceDrawingCanvas = canvases[canvas_index] + assert(canvas.apply_update({ + "session_id": "texture-renderer-session", + "canvas_id": "canvas-%d" % canvas_index, + "revision": 1, + "edits": [{ + "x": canvas_index, + "y": canvas_index, + "color_id": "coral", + "author_fingerprint": FINGERPRINT, + }], + })) + var pixel_instance := canvas.get("_pixel_instance") as MeshInstance3D + var material := pixel_instance.mesh.surface_get_material( + 0 + ) as StandardMaterial3D + assert(pixel_instance.mesh == persistent_meshes[canvas_index]) + assert(material.albedo_texture == persistent_textures[canvas_index]) + assert(canvas.apply_guide_update({ + "session_id": "texture-renderer-session", + "canvas_id": "canvas-%d" % canvas_index, + "revision": 2, + "guide_visible": false, + "finalized": true, + })) + assert(not canvas.has_guide_geometry()) + await process_frame + for canvas: SurfaceDrawingCanvas in canvases: + var artwork_mesh_count: int = 0 + for child: Node in canvas.get_children(): + if child is MultiMeshInstance3D: + assert(false, "Art Kit pixels regressed to per-color MultiMeshes.") + if child is MeshInstance3D: + artwork_mesh_count += 1 + assert(artwork_mesh_count == 1) + assert(canvas.get_export_image().get_size() == Vector2i(32, 32)) + world.queue_free() + await process_frame + print("Surface drawing texture renderer validation: PASS") + quit() diff --git a/tests/surface_drawing_texture_renderer_validation.gd.uid b/tests/surface_drawing_texture_renderer_validation.gd.uid new file mode 100644 index 0000000..c64b594 --- /dev/null +++ b/tests/surface_drawing_texture_renderer_validation.gd.uid @@ -0,0 +1 @@ +uid://qj7jd2p3b4p8 diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index 596cec0..ac48a67 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -90,6 +90,21 @@ func _validate_protocol_bounds() -> void: unsupported_size["width"] = 48 unsupported_size["height"] = 48 assert(not SurfaceDrawingProtocol.validate_canvas_request(unsupported_size)) + var stamp_pixels := PackedByteArray() + stamp_pixels.resize(16 * 16) + stamp_pixels[0] = 1 + var stamp_request: Dictionary = canvas_request.duplicate(true) + stamp_request["request_id"] = "stamp-1" + stamp_request["pixels"] = stamp_pixels + assert(SurfaceDrawingProtocol.validate_stamp_request(stamp_request)) + var invalid_stamp: Dictionary = stamp_request.duplicate(true) + var invalid_pixels: PackedByteArray = invalid_stamp["pixels"] + invalid_pixels[0] = SurfaceDrawingPalette.COLORS.size() + 1 + invalid_stamp["pixels"] = invalid_pixels + assert(not SurfaceDrawingProtocol.validate_stamp_request(invalid_stamp)) + var empty_stamp: Dictionary = stamp_request.duplicate(true) + empty_stamp["pixels"] = PackedByteArray() + assert(not SurfaceDrawingProtocol.validate_stamp_request(empty_stamp)) var guide_request: Dictionary = { "request_id": "guide-1", "session_id": "session", @@ -116,9 +131,11 @@ func _validate_art_unlocks() -> void: assert(unlocks.unlock_product(&"marker_ocean_teal")) assert(unlocks.unlock_product(&"brush_4x")) assert(unlocks.unlock_product(&"grid_128x")) + assert(unlocks.unlock_product(PlayerArtUnlocks.STAMP_PRODUCT_ID)) assert(unlocks.is_color_unlocked(&"ocean_teal")) assert(unlocks.is_brush_size_unlocked(4)) assert(unlocks.is_grid_size_unlocked(128)) + assert(unlocks.is_stamp_unlocked()) assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1)) unlocks.free() @@ -177,6 +194,16 @@ func _validate_canvas_geometry_and_collaboration() -> void: "cells": [], } assert(canvas.setup(state, null)) + var pixel_instance := canvas.get("_pixel_instance") as MeshInstance3D + assert(pixel_instance != null) + assert(pixel_instance.name == "ArtworkTexture") + var persistent_mesh: Mesh = pixel_instance.mesh + var pixel_material := persistent_mesh.surface_get_material( + 0 + ) as StandardMaterial3D + assert(pixel_material != null) + var persistent_texture: Texture2D = pixel_material.albedo_texture + assert(persistent_texture is ImageTexture) 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)) @@ -192,6 +219,15 @@ func _validate_canvas_geometry_and_collaboration() -> void: }], } assert(canvas.apply_update(first_update)) + assert(pixel_instance.mesh == persistent_mesh) + assert(pixel_material.albedo_texture == persistent_texture) + assert(canvas.get_rendered_pixel_count() == 1) + var export_image: Image = canvas.get_export_image() + assert(export_image.get_size() == Vector2i(16, 16)) + assert(export_image.get_pixel(3, 11).is_equal_approx( + SurfaceDrawingPalette.get_color(&"coral") + )) + assert(export_image.get_pixel(0, 0).is_equal_approx(Color.TRANSPARENT)) var finish_update: Dictionary = { "session_id": "session", "canvas_id": "canvas-1", @@ -202,6 +238,9 @@ func _validate_canvas_geometry_and_collaboration() -> void: assert(canvas.apply_guide_update(finish_update)) assert(not canvas.is_guide_visible()) assert(canvas.is_finalized()) + assert(not canvas.has_guide_geometry()) + assert(pixel_instance.mesh == persistent_mesh) + assert(pixel_material.albedo_texture == persistent_texture) await process_frame var finished_pixel_scale: float = canvas.get_rendered_pixel_size() assert( @@ -223,6 +262,8 @@ func _validate_canvas_geometry_and_collaboration() -> void: }], } assert(canvas.apply_update(second_update)) + assert(pixel_instance.mesh == persistent_mesh) + assert(pixel_material.albedo_texture == persistent_texture) var cells: Array[Dictionary] = canvas.get_authoritative_cells() assert(cells.size() == 1) assert(cells[0]["color_id"] == "blue") @@ -240,6 +281,10 @@ func _validate_canvas_geometry_and_collaboration() -> void: } assert(canvas.apply_update(erase_update)) assert(canvas.get_authoritative_cells().is_empty()) + assert(canvas.get_rendered_pixel_count() == 0) + assert(canvas.get_export_image().get_pixel(3, 11).is_equal_approx( + Color.TRANSPARENT + )) world.queue_free() await process_frame @@ -282,6 +327,7 @@ func _validate_blocked_author_visibility() -> void: "cell_size": SurfaceDrawingProtocol.CELL_SIZE, "revision": 1, "creator_fingerprint": FINGERPRINT_A, + "participant_fingerprints": [FINGERPRINT_A, FINGERPRINT_B], "cells": [ { "x": 1, @@ -298,20 +344,30 @@ func _validate_blocked_author_visibility() -> void: ], } assert(canvas.setup(state, relationships)) - assert(_rendered_pixel_count(canvas) == 1) + assert(canvas.is_hidden_by_relationship()) + assert(canvas.get_rendered_pixel_count() == 0) relationships.set("_records", {}) canvas.refresh_relationship_visibility() await process_frame - assert(_rendered_pixel_count(canvas) == 2) + assert(not canvas.is_hidden_by_relationship()) + assert(canvas.get_rendered_pixel_count() == 2) + relationships.set("_records", { + FINGERPRINT_B: {"blocked": true, "muted": true}, + }) + assert(canvas.apply_update({ + "session_id": "session", + "canvas_id": "blocked-author-canvas", + "revision": 2, + "participant_fingerprints": [FINGERPRINT_A, FINGERPRINT_B], + "edits": [{ + "x": 2, + "y": 1, + "color_id": "", + "author_fingerprint": "", + }], + })) + canvas.refresh_relationship_visibility() + assert(canvas.is_hidden_by_relationship()) + assert(canvas.get_rendered_pixel_count() == 0) 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/ui/fishing_shop.gd b/ui/fishing_shop.gd index f56b0ae..ad8247c 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -1118,6 +1118,10 @@ func _refresh_supplies() -> void: var canvas_grid := _add_stock_icon_grid() for product_id: StringName in ArtShopStockType.GRID_PRODUCTS: _add_art_upgrade_button(product_id, canvas_grid) + _add_stock_section("stamps") + var stamp_grid := _add_stock_icon_grid() + for product_id: StringName in ArtShopStockType.STAMP_PRODUCTS: + _add_art_upgrade_button(product_id, stamp_grid) call_deferred("_configure_controller_focus") @@ -1361,7 +1365,9 @@ func _add_art_upgrade_button( var marker_color_id: StringName = ( PlayerArtUnlocksType.color_id_for_product(product_id) ) - if marker_color_id.is_empty(): + if product_id == PlayerArtUnlocksType.STAMP_PRODUCT_ID: + _configure_art_text_tile(button, "stamp") + elif marker_color_id.is_empty(): var upgrade_icon: Texture2D = ART_UPGRADE_ICONS.get( product_id, FALLBACK_SUPPLY_ICON, @@ -1390,6 +1396,16 @@ func _add_art_upgrade_button( button.pressed.connect(_purchase_art_upgrade.bind(product_id)) +func _configure_art_text_tile(button: Button, label_text: String) -> void: + button.custom_minimum_size = SUPPLY_ICON_TILE_SIZE + button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + button.icon = null + button.expand_icon = false + button.alignment = HORIZONTAL_ALIGNMENT_CENTER + button.text = label_text + button.add_theme_font_size_override("font_size", 17) + + func _make_stock_button( button_text: String, tooltip: String, diff --git a/ui/players_page.gd b/ui/players_page.gd index 6748e6d..04e078b 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -522,6 +522,15 @@ func _build_active_rows() -> void: operator.pressed.connect(_confirm_operator.bind(entry)) UtilityPageStyle.apply_compact_ocean_button(operator) row.add_child(operator) + var clear_art := Button.new() + clear_art.text = "clear art" + clear_art.disabled = not entry.can_clear_art + clear_art.tooltip_text = ( + "Remove every shared artwork this player participated in." + ) + clear_art.pressed.connect(_confirm_clear_art.bind(entry)) + UtilityPageStyle.apply_compact_ocean_button(clear_art) + row.add_child(clear_art) var kick := Button.new() kick.disabled = not entry.can_kick kick.pressed.connect(_confirm_kick.bind(entry)) @@ -704,8 +713,21 @@ func _confirm_kick(entry: PlayerListEntry) -> void: ) +func _confirm_clear_art(entry: PlayerListEntry) -> void: + _confirm( + ( + "Clear every shared artwork %s participated in?\n" + + "This removes it for everyone and cannot be undone." + ) % entry.display_name, + func() -> void: + _service.clear_art( + entry.peer_id, entry.full_fingerprint, entry.revision + ) + ) + + func _confirm_ban(entry: PlayerListEntry) -> void: - _confirm("Ban %s · %s from this server?" % [ + _confirm("Ban %s · %s from this server?\nTheir shared artwork will also be removed." % [ entry.display_name, entry.compact_fingerprint, ], func() -> void: _service.ban( diff --git a/ui/surface_drawing_toolbar.gd b/ui/surface_drawing_toolbar.gd index edd920e..af8fda0 100644 --- a/ui/surface_drawing_toolbar.gd +++ b/ui/surface_drawing_toolbar.gd @@ -29,7 +29,7 @@ const GRID_SIZE_ICONS: Dictionary[int, Texture2D] = { 64: preload("res://items/icons/art/art_kit_grid_large_light.png"), 128: preload("res://items/icons/art/art_kit_grid_xl_light.png"), } -const TOOLBAR_WIDTH: float = 510.0 +const TOOLBAR_WIDTH: float = 634.0 const TOOLBAR_HEIGHT: float = 373.0 const COLOR_RAIL_WIDTH: float = 46.0 const POPUP_LOCK_ICON_SIZE: int = 24 @@ -48,6 +48,13 @@ const COLOR_LOCK_ICON_ALPHA: float = 0.34 @onready var _hide_guide_button: Button = %HideGuideButton @onready var _restore_guide_button: Button = %RestoreGuideButton @onready var _finalize_guide_button: Button = %FinalizeGuideButton +@onready var _export_button: Button = %ExportButton +@onready var _stamp_button: Button = %StampButton +@onready var _stamp_library_panel: PanelContainer = %StampLibraryPanel +@onready var _stamp_library_close: Button = %StampLibraryClose +@onready var _empty_stamp_library: Label = %EmptyStampLibrary +@onready var _stamp_scroll: ScrollContainer = %StampScroll +@onready var _stamp_grid: GridContainer = %StampGrid var _service: NetworkSurfaceDrawingService var _unlocks: PlayerArtUnlocks @@ -65,6 +72,7 @@ func _ready() -> void: UtilityPageStyleType.apply_page(self) _apply_toolbar_panel(_top_panel) _apply_toolbar_panel(_color_panel) + _apply_toolbar_panel(_stamp_library_panel) UtilityPageStyleType.apply_ocean_button(_mode_button) UtilityPageStyleType.apply_ocean_button(_brush_option) UtilityPageStyleType.apply_ocean_button(_grid_option) @@ -102,6 +110,9 @@ func _ready() -> void: _finalize_guide_button.pressed.connect( _arm_guide_action.bind(NetworkSurfaceDrawingService.GuideAction.FINALIZE) ) + _export_button.pressed.connect(_export_aimed_artwork) + _stamp_button.pressed.connect(_toggle_stamp_library) + _stamp_library_close.pressed.connect(_close_stamp_library) _brush_option.item_selected.connect(_select_brush) _grid_option.item_selected.connect(_select_grid) _configure_pointer_only_controls() @@ -136,6 +147,8 @@ func _action_buttons() -> Array[Button]: _hide_guide_button, _restore_guide_button, _finalize_guide_button, + _export_button, + _stamp_button, ] @@ -224,6 +237,12 @@ func owns_pointer_event(event: InputEvent) -> bool: return ( _top_panel.get_global_rect().has_point(pointer_position) or _color_panel.get_global_rect().has_point(pointer_position) + or ( + _stamp_library_panel.visible + and _stamp_library_panel.get_global_rect().has_point( + pointer_position + ) + ) ) return false @@ -264,6 +283,9 @@ func _configure_pointer_only_controls() -> void: _hide_guide_button, _restore_guide_button, _finalize_guide_button, + _export_button, + _stamp_button, + _stamp_library_close, ]: control.focus_mode = Control.FOCUS_NONE control.focus_neighbor_left = NodePath() @@ -443,6 +465,14 @@ func _refresh_unlocks() -> void: and _service.get_color_id() == color_id ), ) + _stamp_button.disabled = not _unlocks.is_stamp_unlocked() + _stamp_button.tooltip_text = ( + "Place saved artwork stamps" + if not _stamp_button.disabled + else "Unlock the stamp tool in Art Supplies" + ) + if _stamp_button.disabled: + _close_stamp_library() func _apply_popup_unlock_state( @@ -524,6 +554,65 @@ func _arm_guide_action(action: int) -> void: _service.arm_guide_action(action) +func _export_aimed_artwork() -> void: + if _service != null: + var export_path: String = _service.export_aimed_canvas() + if not export_path.is_empty() and _stamp_library_panel.visible: + _rebuild_stamp_library() + + +func _toggle_stamp_library() -> void: + if _stamp_button.disabled or _service == null: + return + if _stamp_library_panel.visible: + _close_stamp_library() + return + _rebuild_stamp_library() + _stamp_library_panel.show() + + +func _close_stamp_library() -> void: + _stamp_library_panel.hide() + + +func _rebuild_stamp_library() -> void: + for child: Node in _stamp_grid.get_children(): + child.queue_free() + var entries: Array[Dictionary] = ( + _service.get_saved_stamp_entries() if _service != null else [] + ) + _empty_stamp_library.visible = entries.is_empty() + _stamp_scroll.visible = not entries.is_empty() + for entry: Dictionary in entries: + var button := Button.new() + button.custom_minimum_size = Vector2(110.0, 108.0) + button.focus_mode = Control.FOCUS_NONE + button.expand_icon = true + button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + button.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP + button.add_theme_constant_override("icon_max_width", 74) + button.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + button.icon = ImageTexture.create_from_image(entry["image"]) + button.text = "%d×%d" % [entry["grid_size"], entry["grid_size"]] + button.tooltip_text = str(entry["file_name"]) + button.accessibility_name = "saved artwork %s" % str( + entry["file_name"] + ) + button.disabled = not bool(entry.get("available", false)) + if button.disabled: + button.tooltip_text += "\nrequires its grid, colors, and stamp unlock" + UtilityPageStyleType.apply_ocean_button(button) + button.pressed.connect( + _select_saved_stamp.bind(str(entry["path"])) + ) + _stamp_grid.add_child(button) + + +func _select_saved_stamp(path: String) -> void: + if _service != null and _service.select_saved_stamp(path): + _close_stamp_library() + + func _on_unlocks_changed(_unlock_mask: int) -> void: _refresh_unlocks() @@ -539,6 +628,7 @@ func _on_service_state_changed( ) -> void: visible = is_active if not is_active: + _close_stamp_library() hide_virtual_pointer_overlay() return _mode_button.icon = ( diff --git a/ui/surface_drawing_toolbar.tscn b/ui/surface_drawing_toolbar.tscn index 349b209..89e19ac 100644 --- a/ui/surface_drawing_toolbar.tscn +++ b/ui/surface_drawing_toolbar.tscn @@ -20,7 +20,7 @@ layout_mode = 0 anchors_preset = 1 anchor_left = 1.0 anchor_right = 1.0 -offset_left = -510.0 +offset_left = -634.0 offset_bottom = 373.0 grow_horizontal = 0 mouse_filter = 2 @@ -30,7 +30,7 @@ script = ExtResource("1_script") [node name="TopPanel" type="PanelContainer" parent="."] unique_name_in_owner = true layout_mode = 0 -offset_right = 510.0 +offset_right = 634.0 offset_bottom = 60.0 [node name="Top" type="HBoxContainer" parent="TopPanel"] @@ -109,6 +109,22 @@ accessibility_name = "toggle finalize grid mode" icon = ExtResource("9_grid_finish") expand_icon = true +[node name="ExportButton" type="Button" parent="TopPanel/Top"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "export aimed artwork as PNG" +focus_mode = 0 +accessibility_name = "export aimed artwork as PNG" +text = "png" + +[node name="StampButton" type="Button" parent="TopPanel/Top"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "place saved artwork stamps" +focus_mode = 0 +accessibility_name = "place saved artwork stamps" +text = "stamp" + [node name="ModeButton" type="Button" parent="TopPanel/Top"] unique_name_in_owner = true layout_mode = 2 @@ -121,12 +137,74 @@ expand_icon = true [node name="ColorPanel" type="PanelContainer" parent="."] unique_name_in_owner = true layout_mode = 0 -offset_left = 464.0 +offset_left = 588.0 offset_top = 54.0 -offset_right = 510.0 +offset_right = 634.0 offset_bottom = 373.0 [node name="ColorList" type="VBoxContainer" parent="ColorPanel"] unique_name_in_owner = true layout_mode = 2 theme_override_constants/separation = 5 + +[node name="StampLibraryPanel" type="PanelContainer" parent="."] +unique_name_in_owner = true +visible = false +z_index = 10 +layout_mode = 0 +offset_left = 57.0 +offset_top = 64.0 +offset_right = 577.0 +offset_bottom = 366.0 + +[node name="Margin" type="MarginContainer" parent="StampLibraryPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 12 +theme_override_constants/margin_top = 10 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 10 + +[node name="Layout" type="VBoxContainer" parent="StampLibraryPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Header" type="HBoxContainer" parent="StampLibraryPanel/Margin/Layout"] +layout_mode = 2 + +[node name="Title" type="Label" parent="StampLibraryPanel/Margin/Layout/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "saved artwork" +theme_override_font_sizes/font_size = 22 + +[node name="StampLibraryClose" type="Button" parent="StampLibraryPanel/Margin/Layout/Header"] +unique_name_in_owner = true +custom_minimum_size = Vector2(42, 36) +layout_mode = 2 +focus_mode = 0 +tooltip_text = "close saved artwork" +accessibility_name = "close saved artwork" +text = "×" + +[node name="EmptyStampLibrary" type="Label" parent="StampLibraryPanel/Margin/Layout"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +text = "export artwork to add stamps" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="StampScroll" type="ScrollContainer" parent="StampLibraryPanel/Margin/Layout"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="StampGrid" type="GridContainer" parent="StampLibraryPanel/Margin/Layout/StampScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 12 +theme_override_constants/v_separation = 12 +columns = 4