Add artwork export and stamping

This commit is contained in:
Alexander Sellite 2026-08-21 21:01:21 -04:00
parent ba35db1015
commit 8107e2d89c
21 changed files with 1727 additions and 105 deletions

View file

@ -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
if _finalized:
_destroy_grid()
else:
_refresh_grid_visibility()
_rebuild_pixels()
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:

View file

@ -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

View file

@ -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 ""

View file

@ -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

View file

@ -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:

View file

@ -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 (
canvas.contains_world_point(
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 (
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()

View file

@ -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

View file

@ -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))

View file

@ -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"

View file

@ -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", ""

View file

@ -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
)

View file

@ -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 (

View file

@ -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

View file

@ -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("")

View file

@ -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()

View file

@ -0,0 +1 @@
uid://qj7jd2p3b4p8

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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 = (

View file

@ -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