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