Add artwork export and stamping
This commit is contained in:
parent
ba35db1015
commit
8107e2d89c
21 changed files with 1727 additions and 105 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
101
tests/surface_drawing_texture_renderer_validation.gd
Normal file
101
tests/surface_drawing_texture_renderer_validation.gd
Normal 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()
|
||||
1
tests/surface_drawing_texture_renderer_validation.gd.uid
Normal file
1
tests/surface_drawing_texture_renderer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://qj7jd2p3b4p8
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue