feat(player): add avatar-centered drag steering

This commit is contained in:
2026-09-09 23:15:00 +03:00
parent 381eacf403
commit 1396bad76d
27 changed files with 307 additions and 451 deletions
+33 -7
View File
@@ -1561,7 +1561,7 @@ controlled measurement criteria:
## Milestone 017 — Avatar-centered drag steering and latency remediation ## Milestone 017 — Avatar-centered drag steering and latency remediation
**Status:** `NOT_STARTED` **Status:** `IN_PROGRESS`
### Objective ### Objective
@@ -1652,9 +1652,35 @@ immediate and never approaches the observed 500 ms delay.
### Evidence ### Evidence
Not started. This milestone records the project owner's 2026-09-09 decisions: Implementation and automated validation are in progress. This milestone records
the child drags the centered player icon; the icon deflects slightly as the the project owner's 2026-09-09 decisions: the child drags the centered player
control handle while the authoritative map moves below it; displacement controls icon; the icon deflects slightly as the control handle while the authoritative
speed; approximately 100 ms is ideal, 150 ms is acceptable, and delays near map moves below it; displacement controls speed; approximately 100 ms is ideal,
500 ms are unacceptable. Implementation must wait for Milestone 016 to identify 150 ms is acceptable, and delays near 500 ms are unacceptable.
and prioritize the actual latency contributors.
- On 2026-09-09, the browser control was changed from a separate joystick to a
96 by 96 CSS-pixel transparent target around the centered avatar. The visible
avatar deflects at most 28 CSS pixels, uses a 12-pixel dead zone and diagonal
hysteresis, and schedules cardinal repeats from 360 ms to 170 ms. It resets
on release, cancellation, lost pointer capture, pause, disconnect, blur, and
hidden-document events. `LatestIntentFlow` keeps one sent move in flight and
retains at most one replacement intention; release discards the replacement.
The map continues to render only complete server-authoritative projections.
- On 2026-09-09, `node --test test/web/test_player_app.js` passed all 17 tests,
including avatar bounds, cadence, direction hysteresis, latest-intent
coalescing, reset paths, authoritative projections, keyboard/swipe
alternatives, observer immutability, and bounded diagnostics. `bash
scripts/run_host_tests.sh` passed all host tests and deterministic web-asset
verification (`2ca96e65b3aa06385028c420c7e605b2e422f7d0c57cae2d81e277a53ecdf6b3`,
25,121 of 1,572,864 allowed compressed bytes).
- On 2026-09-09, `/home/sasa/.platformio/penv/bin/pio run -e
esp32-c6-devkitm-1` passed with 58,868 of 327,680 RAM bytes and 1,027,966 of
2,097,152 application bytes. `/home/sasa/.platformio/penv/bin/pio run -e
esp32-c6-devkitm-1 --target buildfs` passed and reported a fixed LittleFS
image of 2,031,616 of 2,031,616 bytes.
- `docs/M017_LATENCY_REPORT.md` records the M016 baseline, the browser backlog
remediation, the required isolated-LAN deployment setup, and the controlled
before-and-after retest procedure. The physical mobile usability observation,
isolated-network 200-sample runs, and maximum-session diagnostics are still
required for acceptance criteria 6, 7, 9, and 10. Milestone 017 remains
`IN_PROGRESS`; Milestone 018 has not been started.
Binary file not shown.
-23
View File
@@ -1,23 +0,0 @@
# Maze32 command chamber
This package designs a 12 m × 12 m gameplay-room lobby with a single central
hero: the maze command table. Four stations give players a clear color-coded
place around a protected circulation ring. The open north portal is 3 m wide
and exposes a short arrival apron rather than implying a blocked route.
All final geometry is procedural Blender geometry: slabs, walls, column rhythm,
hero table, stations, ceiling beacon, and lighting. No external mesh or paid
generation is needed. `build_command_chamber.py` uses an Exact Boolean for the
north opening before adding the brass surround.
Run it with a Blender installation:
```sh
blender --background --python build_command_chamber.py -- --output maze32-command-chamber.blend --renders renders
```
The package is deliberately at the Function-review stage. The prop image and
opening schedule have **not** been marked approved, and no form or runtime
approval has been claimed. After a reviewer inspects the production and review
renders, record their explicit decision in `milestone-reviews.json`; then run
the skill validator for the relevant gate.
@@ -1,209 +0,0 @@
"""Build the procedural Maze32 command chamber in Blender 4.x.
Run from this package:
blender --background --python build_command_chamber.py -- --output maze32-command-chamber.blend
The scene intentionally uses no downloaded meshes or textures. The north entry
is cut from the wall with an Exact Boolean before its trim is added.
"""
import argparse
import math
import sys
from pathlib import Path
import bpy
from mathutils import Vector
def cli():
values = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, default=Path("maze32-command-chamber.blend"))
parser.add_argument("--renders", type=Path, default=Path("renders"))
return parser.parse_args(values)
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def material(name, color, metallic=0.0, roughness=0.5, emission=None):
result = bpy.data.materials.new(name)
result.use_nodes = True
node = result.node_tree.nodes["Principled BSDF"]
node.inputs["Base Color"].default_value = color
node.inputs["Metallic"].default_value = metallic
node.inputs["Roughness"].default_value = roughness
if emission:
node.inputs["Emission Color"].default_value = emission
node.inputs["Emission Strength"].default_value = 3.0
return result
SLATE = None
BRASS = None
CYAN = None
COLORS = []
def cube(name, location, dimensions, material_ref, bevel=0.0):
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if bevel:
modifier = obj.modifiers.new("Soft readable edges", "BEVEL")
modifier.width = bevel
modifier.segments = 3
obj.data.materials.append(material_ref)
return obj
def cylinder(name, location, radius, depth, material_ref, vertices=48):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=location)
obj = bpy.context.object
obj.name = name
obj.data.materials.append(material_ref)
bevel = obj.modifiers.new("Edge highlights", "BEVEL")
bevel.width = 0.03
bevel.segments = 2
return obj
def point_camera(camera, target):
camera.rotation_euler = (Vector(target) - camera.location).to_track_quat("-Z", "Y").to_euler()
def boolean_door(wall):
cutter = cube("CUTTER_north-arrival", (0, 5.85, 1.55), (3.0, 0.8, 3.1), SLATE)
modifier = wall.modifiers.new("OPENING_north-arrival_EXACT", "BOOLEAN")
modifier.operation = "DIFFERENCE"
modifier.solver = "EXACT"
modifier.object = cutter
bpy.context.view_layer.objects.active = wall
bpy.ops.object.modifier_apply(modifier=modifier.name)
bpy.data.objects.remove(cutter, do_unlink=True)
def make_shell():
cube("SHELL_floor", (0, 0, -0.15), (12, 12, 0.3), SLATE, 0.05)
cube("SHELL_ceiling", (0, 0, 5.0), (12, 12, 0.35), SLATE, 0.05)
cube("SHELL_south_wall", (0, -5.85, 2.5), (12, 0.3, 5.0), SLATE, 0.05)
west = cube("SHELL_west_wall", (-5.85, 0, 2.5), (0.3, 12, 5.0), SLATE, 0.05)
east = cube("SHELL_east_wall", (5.85, 0, 2.5), (0.3, 12, 5.0), SLATE, 0.05)
north = cube("SHELL_north_wall", (0, 5.85, 2.5), (12, 0.3, 5.0), SLATE, 0.05)
boolean_door(north)
for x in (-1.72, 1.72):
cube("TRIM_north-arrival", (x, 5.62, 1.55), (0.18, 0.24, 3.3), BRASS, 0.03)
cube("TRIM_arrival-header", (0, 5.62, 3.22), (3.62, 0.24, 0.18), BRASS, 0.03)
cube("ENTRY_apron", (0, 6.8, -0.02), (3.0, 2.0, 0.08), BRASS, 0.02)
for x, y in ((-4.8, -4.8), (4.8, -4.8), (-4.8, 4.8), (4.8, 4.8)):
cylinder("ARCH_column", (x, y, 2.2), 0.28, 4.4, BRASS, 32)
def make_command_table():
cylinder("HERO_command_table_base", (0, 0, 0.32), 1.72, 0.64, SLATE)
cylinder("HERO_command_table_brass_ring", (0, 0, 0.68), 1.55, 0.12, BRASS)
cylinder("HERO_command_table_top", (0, 0, 0.76), 1.42, 0.12, SLATE)
cylinder("HERO_hologram_disc", (0, 0, 0.85), 1.16, 0.05, CYAN)
for angle in range(0, 360, 45):
radians = math.radians(angle)
x, y = 0.88 * math.cos(radians), 0.88 * math.sin(radians)
marker = cube("HERO_maze_segment", (x, y, 1.0), (0.42, 0.08, 0.3), CYAN, 0.02)
marker.rotation_euler[2] = radians + math.pi / 2
cylinder("HERO_maze_core", (0, 0, 1.08), 0.24, 0.48, CYAN, 32)
def make_station(name, location, rotation, color):
root = bpy.data.objects.new(name, None)
bpy.context.collection.objects.link(root)
base = cube(name + "_base", location, (1.1, 0.7, 0.18), SLATE, 0.04)
body = cube(name + "_body", (location[0], location[1], 0.55), (0.72, 0.42, 0.7), BRASS, 0.04)
marker = cylinder(name + "_color_marker", (location[0], location[1] - 0.24, 0.94), 0.19, 0.07, color, 32)
for part in (base, body, marker):
part.parent = root
root.rotation_euler[2] = math.radians(rotation)
root["attachment_face"] = "floor"
root["semantic_front"] = "inward"
def make_ceiling():
cylinder("CEILING_light_ring", (0, 0, 4.74), 2.25, 0.1, BRASS)
cylinder("CEILING_beacon", (0, 0, 4.6), 0.32, 0.34, CYAN)
for angle in range(0, 360, 90):
radians = math.radians(angle)
cube("CEILING_radial_trim", (3.5 * math.cos(radians), 3.5 * math.sin(radians), 4.73), (2.5, 0.1, 0.12), BRASS, 0.02).rotation_euler[2] = radians
def light(name, location, color, energy, size=2.0):
data = bpy.data.lights.new(name, "AREA")
data.energy = energy
data.color = color[:3]
data.shape = "DISK"
data.size = size
obj = bpy.data.objects.new(name, data)
bpy.context.collection.objects.link(obj)
obj.location = location
obj.rotation_euler = (0, 0, 0)
return obj
def render_views(camera, output):
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE_NEXT"
scene.render.resolution_x = 1280
scene.render.resolution_y = 720
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
views = {
"production": ((9.2, -10.2, 6.7), (0, 0, 1.5)),
"corner-nw": ((-5.0, 5.0, 3.7), (0, 0, 1.2)),
"corner-ne": ((5.0, 5.0, 3.7), (0, 0, 1.2)),
"corner-se": ((5.0, -5.0, 3.7), (0, 0, 1.2)),
"corner-sw": ((-5.0, -5.0, 3.7), (0, 0, 1.2)),
"ceiling-up": ((0, 0, 0.5), (0, 0, 5.0)),
}
output.mkdir(parents=True, exist_ok=True)
for name, (location, target) in views.items():
camera.location = location
point_camera(camera, target)
scene.render.filepath = str(output / (name + ".png"))
bpy.ops.render.render(write_still=True)
def main():
args = cli()
clear_scene()
global SLATE, BRASS, CYAN, COLORS
SLATE = material("MAT_slate", (0.055, 0.075, 0.11, 1), 0.12, 0.68)
BRASS = material("MAT_brass", (0.42, 0.22, 0.045, 1), 0.8, 0.28)
CYAN = material("MAT_hologram", (0.0, 0.55, 0.75, 1), 0.1, 0.25, (0.0, 0.75, 1.0, 1))
COLORS = [material("MAT_red", (0.85, 0.05, 0.05, 1), emission=(0.85, 0.02, 0.02, 1)), material("MAT_yellow", (0.9, 0.65, 0.02, 1), emission=(0.9, 0.5, 0.0, 1)), material("MAT_green", (0.05, 0.72, 0.25, 1), emission=(0.0, 0.65, 0.18, 1)), material("MAT_blue", (0.05, 0.28, 0.9, 1), emission=(0.02, 0.15, 0.85, 1))]
make_shell()
make_command_table()
make_station("STATION_north", (0, 3.95, 0.09), 180, COLORS[0])
make_station("STATION_east", (3.95, 0, 0.09), 90, COLORS[1])
make_station("STATION_south", (0, -3.95, 0.09), 0, COLORS[2])
make_station("STATION_west", (-3.95, 0, 0.09), -90, COLORS[3])
make_ceiling()
camera_data = bpy.data.cameras.new("ProductionCamera")
camera = bpy.data.objects.new("ProductionCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera_data.lens = 35
bpy.context.scene.camera = camera
light("Key", (0, 0, 4.5), (0.7, 0.85, 1.0, 1), 900, 5.0)
light("Warm arrival", (0, 4.9, 3.8), (1.0, 0.5, 0.15, 1), 650, 2.5)
render_views(camera, args.renders)
scene = bpy.context.scene
scene["room_id"] = "maze32-command-chamber"
scene["room_family"] = "gameplay-room"
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 1.0
bpy.ops.wm.save_as_mainfile(filepath=str(args.output.resolve()))
if __name__ == "__main__":
main()
@@ -1,10 +0,0 @@
{
"schema": "game-room.room-final-report.v1",
"roomId": "maze32-command-chamber",
"gates": {"function": false, "form": false, "runtime": false},
"formChecks": {"floorPlanParity": false, "primaryArrival": false, "symmetry": false, "shell": false, "manifold": false, "booleans": false, "collisions": false, "support": false, "attachmentOrientation": false, "ceilingAttachmentOrientation": false, "seatingOrientation": false, "focalDistribution": false, "heroClearance": false, "colonnades": false, "screenClearance": false, "scale": false, "camera": false, "ceilingCoverage": false, "environmentCoverage": false, "provenance": false},
"reviewRenders": {"corners": [], "ceiling": [], "environment": {}},
"runtimeChecks": {"vlow": false, "textureTiers": false, "lightmap": false, "lightingManifest": false, "performance": false, "browserCapture": "", "errors": []},
"postmortem": {"observations": [], "corrections": [], "remainingRisks": []},
"proposedLessons": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,8 +0,0 @@
{
"id": "north-arrival",
"shape": "rectangular-door",
"centerMeters": [0, 5.85, 1.55],
"dimensionsMeters": [3.0, 0.8, 3.1],
"wall": "north",
"operation": "EXACT_BOOLEAN_DIFFERENCE"
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400"><rect width="600" height="400" fill="white"/><rect x="150" y="60" width="300" height="310" fill="black"/><path d="M150 370V60H450V370" fill="none" stroke="#777" stroke-width="4"/><text x="300" y="35" text-anchor="middle" font-family="sans-serif" font-size="20">north-arrival: 3.0 m × 3.1 m</text></svg>

Before

Width:  |  Height:  |  Size: 391 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" fill="white"/><text x="40" y="48" font-family="sans-serif" font-size="26" fill="black">Opening mask contact sheet</text><rect x="80" y="110" width="330" height="340" fill="#111"/><path d="M80 450V110H410V450" fill="none" stroke="#777" stroke-width="5"/><text x="245" y="495" text-anchor="middle" font-family="sans-serif" font-size="22">north-arrival · door · 3.0 × 3.1 m</text><text x="500" y="170" font-family="sans-serif" font-size="20" fill="black">Cutter: rectangular Exact Boolean</text><text x="500" y="210" font-family="sans-serif" font-size="20" fill="black">Wall: north</text><text x="500" y="250" font-family="sans-serif" font-size="20" fill="black">Passage depth: 0.8 m</text><text x="500" y="290" font-family="sans-serif" font-size="20" fill="black">Destination: entry apron</text></svg>

Before

Width:  |  Height:  |  Size: 921 B

@@ -1,7 +0,0 @@
{
"schema": "game-room.milestone-reviews.v1",
"roomId": "maze32-command-chamber",
"function": {"status": "pending", "approvedBy": "", "approvedAt": "", "evidence": [], "strangestElement": "", "notes": ""},
"form": {"status": "pending", "approvedBy": "", "approvedAt": "", "evidence": [], "strangestElement": "", "notes": ""},
"runtime": {"status": "pending", "approvedBy": "", "approvedAt": "", "evidence": [], "strangestElement": "", "notes": ""}
}
@@ -1,8 +0,0 @@
{
"schema": "game-room.opening-schedule.v1",
"roomId": "maze32-command-chamber",
"primaryArrival": {"openingId": "north-arrival", "exception": ""},
"openings": [
{"id": "north-arrival", "kind": "door", "wall": "north", "widthMeters": 3.0, "heightMeters": 3.1, "depthMeters": 0.45, "thresholdMeters": 0, "destination": "A short visible entry apron leading from the maze game's lobby arrival point.", "mask": "masks/north-arrival-mask.svg", "cutter": "masks/north-arrival-cutter.json", "approved": false}
]
}
@@ -1,11 +0,0 @@
{
"schema": "game-room.plan-metadata.v1",
"roomId": "maze32-command-chamber",
"style": "monochrome-engineering",
"projectionSource": "actual-meshes",
"layers": ["lower-room", "reflected-ceiling"],
"semanticLabelsOutsideImage": true,
"aiInspirationIncluded": false,
"aiInspirationReason": "",
"coverage": ["room-outline", "cutouts", "openings", "exterior-apron", "main-sign", "ingress-props", "floor-markers", "barriers", "columns", "circulation", "placed-features"]
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900" viewBox="0 0 900 900"><rect width="900" height="900" fill="white"/><g fill="none" stroke="#111" stroke-width="6"><rect x="130" y="130" width="640" height="640"/><path d="M370 130H530" stroke="white" stroke-width="18"/><path d="M370 130H530"/><circle cx="450" cy="450" r="135"/><circle cx="450" cy="450" r="215" stroke="#777" stroke-dasharray="14 10"/><circle cx="450" cy="235" r="40"/><circle cx="665" cy="450" r="40"/><circle cx="450" cy="665" r="40"/><circle cx="235" cy="450" r="40"/><rect x="370" y="65" width="160" height="65"/><path d="M450 315V130M585 450H770M450 585V770M315 450H130" stroke="#777" stroke-width="4" stroke-dasharray="12 12"/></g><g font-family="sans-serif" font-size="20" fill="#111"><text x="450" y="35" text-anchor="middle">LOWER-ROOM PLAN — 12 m × 12 m</text><text x="450" y="457" text-anchor="middle">command table</text><text x="450" y="242" text-anchor="middle">station</text><text x="670" y="457" text-anchor="middle">station</text><text x="450" y="672" text-anchor="middle">station</text><text x="230" y="457" text-anchor="middle">station</text><text x="450" y="110" text-anchor="middle">3 m arrival + apron</text><text x="610" y="320">1.4 m circulation ring</text><text x="700" y="810">black: shell · dashed: paths</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="500" viewBox="0 0 900 500"><rect width="900" height="500" fill="white"/><path d="M110 430V90H790V430H110" fill="none" stroke="#111" stroke-width="6"/><path d="M365 430V170H535V430" fill="none" stroke="#111" stroke-width="6"/><path d="M110 90H790" stroke="#777" stroke-width="3" stroke-dasharray="12 10"/><g font-family="sans-serif" font-size="20" fill="#111"><text x="450" y="45" text-anchor="middle">NORTH WALL ELEVATION</text><text x="450" y="470" text-anchor="middle">arrival portal: 3.0 m wide × 3.1 m high · actual Boolean aperture</text><text x="700" y="120">ceiling: 5.2 m</text></g></svg>

Before

Width:  |  Height:  |  Size: 659 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900" viewBox="0 0 900 900"><rect width="900" height="900" fill="white"/><g fill="none" stroke="#111" stroke-width="6"><rect x="130" y="130" width="640" height="640"/><path d="M370 130H530" stroke="white" stroke-width="18"/><path d="M370 130H530"/><circle cx="450" cy="450" r="170"/><circle cx="450" cy="450" r="230" stroke="#777" stroke-dasharray="14 10"/><circle cx="450" cy="450" r="35"/></g><g font-family="sans-serif" font-size="20" fill="#111"><text x="450" y="35" text-anchor="middle">REFLECTED CEILING PLAN — 12 m × 12 m</text><text x="450" y="455" text-anchor="middle">central suspended beacon</text><text x="450" y="285" text-anchor="middle">ceiling light ring</text><text x="450" y="700" text-anchor="middle">outer trim ring</text></g></svg>

Before

Width:  |  Height:  |  Size: 815 B

-17
View File
@@ -1,17 +0,0 @@
{
"schema": "game-room.prop-manifest.v1",
"roomId": "maze32-command-chamber",
"generation": {
"provider": "meshy",
"apiModel": "meshy-5",
"pricingSource": "https://docs.meshy.ai/en/api/pricing",
"pricingVerifiedAt": "2026-09-09T00:00:00Z (no Meshy submission planned; procedural Blender construction selected)",
"creditsPerTexturedTask": 15,
"hardCreditCeiling": 1,
"maxChargedAttemptsPerAsset": 2
},
"assets": [
{"id": "command-table", "function": "Explains the maze objective and anchors the lobby.", "description": "Circular slate, brass, and cyan holographic maze pedestal, constructed procedurally in Blender.", "class": "hero", "dimensionsMeters": [3.6, 3.6, 1.35], "attachmentFace": "floor", "semanticFront": "positive-y", "targetPolycount": 8000, "intendedInstances": 1, "rejectionCriteria": ["The base cannot sit flat on the floor.", "The table silhouette hides the holographic maze from the production camera.", "The construction has fused or floating support parts."], "image": {"prompt": "Isolated circular command-table pedestal for a child-friendly maze game; full uncropped three-quarter view; dark slate, warm brass, cyan holographic maze; neutral studio background; no text, room, people, crop, watermark, floating parts, or fused background.", "generator": "OpenAI image generation", "source": "images/command-table-reference.png", "approved": false, "approvedBy": ""}, "attempts": []},
{"id": "player-station", "function": "Provides one readable standing point for each player color.", "description": "Low outward-facing brass-and-slate kiosk with a circular color marker; constructed procedurally in Blender.", "class": "reusable", "dimensionsMeters": [1.1, 0.7, 1.05], "attachmentFace": "floor", "semanticFront": "negative-y", "targetPolycount": 1200, "intendedInstances": 4, "rejectionCriteria": ["The station blocks the 1.2 m circulation ring.", "The color marker does not face the room center.", "The base floats or balances on an edge."], "image": {"prompt": "Isolated low player station kiosk, simple stone and brass body with circular colored floor marker, full uncropped three-quarter studio view; no text, room, people, crop, watermark, floating parts, or fused background.", "generator": "Procedural Blender design brief", "source": "images/command-table-reference.png", "approved": false, "approvedBy": ""}, "attempts": []}
]
}
@@ -1,16 +0,0 @@
{
"schema": "game-room.room-brief.v1",
"roomId": "maze32-command-chamber",
"family": "gameplay-room",
"purpose": "A readable pre-match command chamber where players select a color station, understand the maze objective, and enter the game through a single generous portal.",
"runtimeSurface": "Future WebGL maze-game lobby; the Blender source is a visual and spatial reference, not a runtime export.",
"boundsMeters": [12, 12, 5.2],
"hero": {"id": "command-table", "role": "Central holographic maze table; its 3.6 m circular silhouette remains unobstructed from the production camera.", "reservedBoundsMeters": [4.4, 4.4, 2.2]},
"symmetry": {"mode": "radial", "order": 4, "appliesTo": ["architecture", "barriers", "circulation", "repeatedProps"], "exceptions": ["The north arrival is a single 3.0 m-wide portal; the other three wall bays retain the same structural rhythm but are closed."]},
"circulation": {"description": "A 1.4 m radial ring around the command table connects the 3.0 m north arrival to four outward-facing player stations without crossing the hero reserve.", "minimumClearanceMeters": 1.2},
"productionCamera": {"viewport": [1920, 1080], "location": [9.2, -10.2, 6.7], "target": [0, 0, 1.5], "verticalFovDegrees": 54},
"performanceBudget": {"maxSceneTriangles": 120000, "maxVLowBytes": 3000000, "maxHighTextureBytes": 16000000},
"creditBudget": {"maximumCredits": 1, "approvalRequired": true},
"environment": {"type": "none", "identity": "", "source": "", "assignmentKey": ""},
"artifacts": {"floorPlan": "plans/floor-plan.svg", "reflectedCeilingPlan": "plans/reflected-ceiling-plan.svg", "planMetadata": "plan-metadata.json", "wallElevations": ["plans/north-elevation.svg"], "visualTarget": "images/command-table-reference.png", "openingContactSheet": "masks/opening-contact-sheet.svg"}
}
@@ -1,17 +0,0 @@
{
"schema": "game-room.layout.v1",
"roomId": "maze32-command-chamber",
"units": "meters",
"surfaces": [
{"id": "floor", "kind": "floor", "location": [0, 0, -0.15], "dimensionsMeters": [12, 12, 0.3], "rotationDegrees": [0, 0, 0], "material": {"baseColor": [0.07, 0.09, 0.13, 1], "metallic": 0.05, "roughness": 0.72}},
{"id": "ceiling", "kind": "ceiling", "location": [0, 0, 5.0], "dimensionsMeters": [12, 12, 0.35], "rotationDegrees": [0, 0, 0], "material": {"baseColor": [0.04, 0.05, 0.08, 1], "metallic": 0.1, "roughness": 0.64}}
],
"instances": [
{"id": "command-table-main", "assetId": "command-table", "location": [0, 0, 0], "rotationDegrees": [0, 0, 0], "scale": [1, 1, 1]},
{"id": "station-north", "assetId": "player-station", "location": [0, 3.95, 0], "rotationDegrees": [0, 0, 180], "scale": [1, 1, 1]},
{"id": "station-east", "assetId": "player-station", "location": [3.95, 0, 0], "rotationDegrees": [0, 0, 90], "scale": [1, 1, 1]},
{"id": "station-south", "assetId": "player-station", "location": [0, -3.95, 0], "rotationDegrees": [0, 0, 0], "scale": [1, 1, 1]},
{"id": "station-west", "assetId": "player-station", "location": [-3.95, 0, 0], "rotationDegrees": [0, 0, -90], "scale": [1, 1, 1]}
],
"productionCamera": {"location": [9.2, -10.2, 6.7], "target": [0, 0, 1.5], "verticalFovDegrees": 54}
}
+49
View File
@@ -0,0 +1,49 @@
# Milestone 017 latency remediation report
## Baseline
Milestone 016 recorded an unsuitable local Wi-Fi tail: 100 HTTP requests had
7.5 ms minimum, 186.7 ms average, 508.0 ms p95, 713.3 ms p99, and 985.4 ms
maximum round-trip time. The available browser samples ranged from 19 to 548
ms for command acknowledgement and from 30 to 551 ms for an authoritative
painted view. ESP handler and send measurements were materially smaller during
the partial runs. The M016 project-owner-approved deviation records that the
remaining controlled runs could not be completed on that path.
## Browser remediation
The player now uses the centered avatar as a 96 by 96 CSS-pixel transparent
touch target. Pointer movement changes only a local avatar deflection capped at
28 CSS pixels. A 12-pixel dead zone, diagonal hysteresis, and a 360-to-170 ms
monotonic cadence resolve a cardinal movement intention without exceeding the
150 ms server cooldown.
The browser sends at most one move at a time. While that command awaits an
acknowledgement or error, changing drag direction replaces one local latest
intention. Release removes the unsent intention. Consequently a slow socket
cannot accumulate a client-side movement backlog. No browser code predicts a
maze position, collision result, inventory, visibility, timer, or outcome.
## Required deployment-network remediation and retest
Do not use the M016 Wi-Fi path for latency acceptance. Place the ESP and both
test clients on the same dedicated 5 GHz access point or the ESP fallback AP,
with no WAN routing, captive portal, VPN, power-saving transition, or active
bulk traffic. Record access-point model/band/channel, client hardware,
browser/version, RSSI, device address, build identity, and viewport.
Repeat the M016 procedure for two players with zero observers and with four
observers. For each player, collect at least 200 accepted moves and export:
```js
window.maze32PlayerApp.pointerFeedbackSamples()
window.maze32PlayerApp.latencySamples()
```
Record p50, p95, p99, and maximum values separately for pointer feedback,
command acknowledgement, complete authoritative view, and painted
authoritative view. Capture `/diagnostics` before and after each run and 100
ICMP plus application/WebSocket round trips. The acceptance target remains
painted-update p95 at or below 100 ms, maximum at or below 150 ms, and no
accepted sample at or above 500 ms. Do not mark this report as a passing
device measurement until those controlled results exist.
+10
View File
@@ -20,6 +20,16 @@ layouts hide the direction pad and retain swipes; tablet and desktop layouts
show the direction pad and also accept swipes. The persistent `OK` control show the direction pad and also accept swipes. The persistent `OK` control
sends `drop_key` and exposes the accessible label `Сбросить ключ`. sends `drop_key` and exposes the accessible label `Сбросить ключ`.
The centered player avatar is also the touch movement handle. Its transparent
target is 96 by 96 CSS pixels while the visible avatar can deflect at most 28
CSS pixels. A 12-pixel dead zone and diagonal hysteresis resolve only cardinal
intentions. Drag displacement selects a monotonically bounded local cadence
from 360 ms to 170 ms, which remains slower than the authoritative 150 ms
cooldown. The browser keeps one sent move in flight and coalesces all later
drag changes into one latest intention; release discards that unsent intention.
The deflection is local input feedback only. The maze redraws only after a
server-authoritative projection and the browser never predicts a cell change.
Commands use the version-one protocol envelope and contain intention data only: Commands use the version-one protocol envelope and contain intention data only:
```json ```json
+1 -1
View File
@@ -21,7 +21,7 @@ child succeeds without reading the Russian support text.
| Task | Expected non-reading cue | | Task | Expected non-reading cue |
| --- | --- | | --- | --- |
| Move the hero | circular joystick and character movement | | Move the hero | centered character drag, direction arrow, and map movement |
| Find and pick up a key | key shape and pickup feedback | | Find and pick up a key | key shape and pickup feedback |
| Recognize farther vision | eye symbol and map expansion | | Recognize farther vision | eye symbol and map expansion |
| Follow the compass | arrow direction and compass icon | | Follow the compass | arrow direction and compass icon |
+11 -9
View File
@@ -64,14 +64,16 @@ are not MVP requirements.
- Visible keyboard-focus outlines are not required for the MVP. - Visible keyboard-focus outlines are not required for the MVP.
- On computers, movement works from the keyboard and from an on-screen - On computers, movement works from the keyboard and from an on-screen
directional control cluster fixed in the lower-right corner. directional control cluster fixed in the lower-right corner.
- On touch devices, movement uses an always-visible circular joystick at least - On touch devices, the centered player avatar is the movement handle. Its
160 by 160 CSS pixels. Its drag stays within the control, returns to center transparent touch target is at least 96 by 96 CSS pixels and its visible
on release, cancellation, pause, disconnect, and browser blur, and sends character remains unobscured. During a drag, it deflects no more than 28 CSS
only cardinal movement intentions. pixels from center and returns exactly to center on release, cancellation,
- The joystick has a dead zone and directional hysteresis. Its locally pause, disconnect, lost pointer capture, hidden document, and browser blur.
scheduled repeat interval is bounded and never faster than the server's - The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its
movement cooldown. Touch swipes remain supported. Phones do not show a bounded repeat cadence changes monotonically from 360 ms at a small
directional-button cluster. intentional displacement to 170 ms at the target edge; it never exceeds the
server's 150 ms movement cooldown. Touch swipes remain supported. Phones do
not show a directional-button cluster.
- The always-visible key-drop control is labelled `OK`, includes a key icon, - The always-visible key-drop control is labelled `OK`, includes a key icon,
and is positioned closer to the upper edge of the screen than the and is positioned closer to the upper edge of the screen than the
directional controls. Dropping does not require confirmation. directional controls. Dropping does not require confirmation.
@@ -160,7 +162,7 @@ are not MVP requirements.
## 12. Pre-reader onboarding and usability ## 12. Pre-reader onboarding and usability
- On a player's first active projection, show an eight-step, wordless-first - On a player's first active projection, show an eight-step, wordless-first
walkthrough for joystick drag and release, walls, key, vision, compass, walkthrough for avatar drag and release, walls, key, vision, compass,
exit, and meeting the other player. Each step uses a large icon; short exit, and meeting the other player. Each step uses a large icon; short
Russian text is secondary support for an adult. Russian text is secondary support for an adult.
- The walkthrough provides a replay button and an explicit Russian skip button - The walkthrough provides a replay button and an explicit Russian skip button
+52 -23
View File
@@ -22,25 +22,45 @@ test("keyboard and swipe controls produce equivalent directions", () => {
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null); assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
}); });
test("joystick produces only cardinal, bounded movement intentions", () => { test("avatar drag produces only cardinal, bounded movement intentions", () => {
assert.equal(player.joystickDirection(10, 10, null), null); assert.equal(player.avatarDirection(10, 10, null), null);
assert.equal(player.joystickDirection(44, 8, null), "east"); assert.equal(player.avatarDirection(44, 8, null), "east");
assert.equal(player.joystickDirection(-8, -44, null), "north"); assert.equal(player.avatarDirection(-8, -44, null), "north");
assert.equal(player.joystickDirection(42, 38, "east"), "east"); assert.equal(player.avatarDirection(42, 38, "east"), "east");
assert.equal(player.joystickDirection(38, 42, "north"), "south"); assert.equal(player.avatarDirection(38, 42, "north"), "south");
const vector = player.joystickVector(300, 0, 57, null); const vector = player.avatarVector(300, 0, 48, null);
assert.equal(vector.direction, "east"); assert.equal(vector.direction, "east");
assert.equal(vector.x, 57); assert.equal(vector.x, player.AVATAR_MAX_DEFLECTION);
assert.equal(vector.y, 0); assert.equal(vector.y, 0);
assert.ok(vector.distance <= player.AVATAR_MAX_DEFLECTION);
}); });
test("joystick repeat interval is monotonic and remains slower than cooldown", () => { test("avatar cadence is monotonic and remains slower than cooldown", () => {
assert.equal(player.joystickRepeatMs(player.JOYSTICK_DEAD_ZONE, 60), null); assert.equal(player.avatarRepeatMs(player.AVATAR_DEAD_ZONE, 48), null);
const near = player.joystickRepeatMs(25, 60); const near = player.avatarRepeatMs(18, 48);
const far = player.joystickRepeatMs(60, 60); const far = player.avatarRepeatMs(48, 48);
assert.ok(near > far); assert.ok(near > far);
assert.ok(far >= player.JOYSTICK_FAST_REPEAT_MS); assert.ok(far >= player.AVATAR_FAST_REPEAT_MS);
assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150); assert.ok(player.AVATAR_FAST_REPEAT_MS > 150);
});
test("latest movement intent coalesces while one command is in flight", () => {
const dispatched = [];
const flow = new player.LatestIntentFlow(direction => {
const requestId = `move-${dispatched.length + 1}`;
dispatched.push({direction, requestId});
return requestId;
});
assert.equal(flow.request("east"), "move-1");
assert.equal(flow.request("north"), null);
assert.equal(flow.request("west"), null);
assert.deepEqual(dispatched.map(item => item.direction), ["east"]);
flow.complete("move-1");
assert.deepEqual(dispatched.map(item => item.direction), ["east", "west"]);
flow.release();
flow.complete("move-2");
assert.equal(flow.inFlight, null);
assert.equal(flow.latest, null);
}); });
test("latency correlation is bounded and only completes matching authoritative views", () => { test("latency correlation is bounded and only completes matching authoritative views", () => {
@@ -60,6 +80,11 @@ test("latency correlation is bounded and only completes matching authoritative v
tracker.sent(`move-${index + 2}`); tracker.sent(`move-${index + 2}`);
} }
assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT); assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT);
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
clock++;
tracker.pointerPainted(0);
}
assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT);
}); });
test("player map cells enlarge small projections without exceeding a readable bound", () => { test("player map cells enlarge small projections without exceeding a readable bound", () => {
@@ -77,16 +102,19 @@ test("onboarding has bounded, replayable steps", () => {
assert.equal(player.nextOnboardingStep(7), null); assert.equal(player.nextOnboardingStep(7), null);
}); });
test("child controls reset safely and retain accessible alternatives", () => { test("avatar control resets safely and retains accessible alternatives", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /addEventListener\("pointerup", stopJoystick\)/); assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/);
assert.match(script, /addEventListener\("pointercancel", stopJoystick\)/); assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/);
assert.match(script, /addEventListener\("blur", resetJoystick\)/); assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/);
assert.match(script, /connectionState !== "online"\) resetJoystick\(\)/); assert.match(script, /addEventListener\("blur", resetAvatarControl\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetJoystick\(\)/); assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/);
assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/);
assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setMuted\(!state\.muted\)/);
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /aria-label="Тянуть героя для движения"/);
assert.match(script, /onboarding-replay/); assert.match(script, /onboarding-replay/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/); assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
}); });
@@ -181,11 +209,12 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/); assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/);
assert.match(css, /height: calc\(100dvh - 78px\)/); assert.match(css, /height: calc\(100dvh - 78px\)/);
assert.match(css, /\.dpad \{ display: none; \}/); assert.match(css, /\.dpad \{ display: none; \}/);
assert.match(html, /id="joystick"/); assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/); assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/); assert.match(html, /id="mute-toggle"/);
assert.match(css, /\.joystick \{[^}]*width: 180px; height: 180px/); assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(script, /resetJoystick/); assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(script, /resetAvatarControl/);
assert.match(script, /playerCellSize/); assert.match(script, /playerCellSize/);
assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/); assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/);
assert.match(icons, /id="key"/); assert.match(icons, /id="key"/);
+1 -1
View File
@@ -52,11 +52,11 @@
<div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения"> <div class="map-shell" id="map-shell" tabindex="0" aria-label="Лабиринт. Используйте стрелки, WASD или свайпы для движения">
<div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div> <div class="unknown-message" id="map-placeholder"><svg aria-hidden="true"><use href="/icons.svg#radar"></use></svg><span>Ждём разрешённый сервером кусочек карты…</span></div>
<div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div> <div class="maze" id="maze" role="img" aria-label="Видимая часть лабиринта"></div>
<button class="avatar-control" id="avatar-control" type="button" aria-label="Тянуть героя для движения"><span class="avatar-handle" id="avatar-handle" aria-hidden="true"><span id="avatar-handle-symbol">●</span><svg><use href="/icons.svg#arrow"></use></svg></span></button>
<div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div> <div class="wall-bump" id="wall-bump" role="status" aria-live="polite"></div>
</div> </div>
<div class="child-controls" aria-label="Игровые действия"> <div class="child-controls" aria-label="Игровые действия">
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button> <button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
<div class="joystick" id="joystick" role="application" aria-label="Джойстик движения"><span class="joystick-ring" aria-hidden="true"></span><span class="joystick-handle" id="joystick-handle" aria-hidden="true"><svg><use href="/icons.svg#arrow"></use></svg></span></div>
</div> </div>
<div class="access-controls" aria-label="Настройки игры"> <div class="access-controls" aria-label="Настройки игры">
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button> <button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
+13 -7
View File
@@ -137,13 +137,21 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.wall-bump.show { animation: bump .8s steps(3, end); } .wall-bump.show { animation: bump .8s steps(3, end); }
.wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; } .wall-bump::before { content: "▌"; display: inline-block; margin-right: 6px; font-size: 1.35em; font-weight: 1000; }
.avatar-control { position: absolute; z-index: 8; left: 50%; top: 50%; width: 96px; height: 96px; margin: -48px; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; user-select: none; }
.avatar-control:active, .avatar-control.active { cursor: grabbing; }
.avatar-handle { position: absolute; left: 50%; top: 50%; width: 52px; height: 52px; display: grid; place-items: center; border: 3px solid currentColor; border-radius: 50%; background: #0b0712; color: var(--p1); box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead236; font-size: 1.25rem; font-weight: 1000; transform: translate(-50%, -50%); transition: transform .12s steps(3, end); }
.player-two-mode .avatar-handle { color: var(--p2); }
.avatar-control.active .avatar-handle { transition: none; }
.avatar-handle svg { position: absolute; width: 19px; height: 19px; right: -13px; top: -13px; display: none; padding: 2px; border: 1px solid currentColor; background: #08050b; color: var(--aqua); }
.avatar-control[data-direction] .avatar-handle svg { display: block; }
.avatar-control[data-direction="north"] .avatar-handle svg { transform: rotate(0deg); }
.avatar-control[data-direction="east"] .avatar-handle svg { transform: rotate(90deg); }
.avatar-control[data-direction="south"] .avatar-handle svg { transform: rotate(180deg); }
.avatar-control[data-direction="west"] .avatar-handle svg { transform: rotate(270deg); }
.avatar-control[data-speed="fast"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 4px var(--aqua), 0 0 18px #43ead299; }
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; } .child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
.child-controls > * { pointer-events: auto; } .child-controls > * { pointer-events: auto; }
.joystick { position: relative; width: 180px; height: 180px; border: 5px solid var(--aqua); border-radius: 50%; background: #102a30dd; box-shadow: inset 0 0 0 12px #43ead214, 5px 5px 0 #020506; touch-action: none; user-select: none; }
.joystick-ring { position: absolute; inset: 18px; border: 2px dashed #43ead266; border-radius: 50%; }
.joystick-handle { position: absolute; left: 50%; top: 50%; width: 66px; height: 66px; display: grid; place-items: center; border: 3px solid var(--ink); border-radius: 50%; background: var(--aqua); color: #06211f; box-shadow: 4px 4px 0 #020506; transform: translate(-50%, -50%); transition: transform .08s linear; }
.joystick-handle svg { width: 38px; height: 38px; }
.joystick.active .joystick-handle { transition: none; }
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; } .access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; } .access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
.access-button svg { width: 24px; height: 24px; } .access-button svg { width: 24px; height: 24px; }
@@ -242,7 +250,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.stat svg { width: 27px; height: 27px; flex-basis: 27px; } .stat svg { width: 27px; height: 27px; flex-basis: 27px; }
.map-shell { inset: 82px 0 0; } .map-shell { inset: 82px 0 0; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; } .child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
.joystick { width: 172px; height: 172px; }
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; } .drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.access-controls { top: 88px; right: 8px; } .access-controls { top: 88px; right: 8px; }
.dpad { display: none; } .dpad { display: none; }
@@ -262,7 +269,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.identity, .stat { min-height: 55px; } .identity, .stat { min-height: 55px; }
.map-shell { inset: 64px 0 0; } .map-shell { inset: 64px 0 0; }
.drop-key { top: 76px; right: 14px; width: 70px; height: 70px; } .drop-key { top: 76px; right: 14px; width: 70px; height: 70px; }
.joystick { width: 150px; height: 150px; }
} }
@media (hover: none) and (pointer: coarse) and (max-height: 720px) { @media (hover: none) and (pointer: coarse) and (max-height: 720px) {
+137 -72
View File
@@ -8,13 +8,15 @@
const MAX_VIEW_CELLS = 1024; const MAX_VIEW_CELLS = 1024;
const MAX_VIEW_OBJECTS = 12; const MAX_VIEW_OBJECTS = 12;
const OBSERVER_CELL_SIZE = 8; const OBSERVER_CELL_SIZE = 8;
const JOYSTICK_DEAD_ZONE = 20; const AVATAR_TOUCH_TARGET_SIZE = 96;
const JOYSTICK_HYSTERESIS = .18; const AVATAR_MAX_DEFLECTION = 28;
const JOYSTICK_SLOW_REPEAT_MS = 360; const AVATAR_DEAD_ZONE = 12;
const JOYSTICK_FAST_REPEAT_MS = 170; const AVATAR_DIRECTION_HYSTERESIS = .18;
const AVATAR_SLOW_REPEAT_MS = 360;
const AVATAR_FAST_REPEAT_MS = 170;
const LATENCY_SAMPLE_LIMIT = 256; const LATENCY_SAMPLE_LIMIT = 256;
const ONBOARDING_STEPS = Object.freeze([ const ONBOARDING_STEPS = Object.freeze([
Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}), Object.freeze({icon: "hand", title: "Потяни героя", text: "Потяни героя — лабиринт поедет под ним."}),
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}), Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}), Object.freeze({icon: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}),
Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}), Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}),
@@ -107,36 +109,71 @@
return Math.min(maximum, Math.max(minimum, value)); return Math.min(maximum, Math.max(minimum, value));
} }
function joystickDirection(dx, dy, previousDirection) { function avatarDirection(dx, dy, previousDirection) {
const horizontal = Math.abs(dx); const horizontal = Math.abs(dx);
const vertical = Math.abs(dy); const vertical = Math.abs(dy);
if (Math.max(horizontal, vertical) < JOYSTICK_DEAD_ZONE) return null; if (Math.max(horizontal, vertical) < AVATAR_DEAD_ZONE) return null;
const previousWasHorizontal = previousDirection === "east" || previousDirection === "west"; const previousWasHorizontal = previousDirection === "east" || previousDirection === "west";
const previousWasVertical = previousDirection === "north" || previousDirection === "south"; const previousWasVertical = previousDirection === "north" || previousDirection === "south";
if (previousWasHorizontal && horizontal >= vertical * (1 - JOYSTICK_HYSTERESIS)) return dx >= 0 ? "east" : "west"; if (previousWasHorizontal && horizontal >= vertical * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dx >= 0 ? "east" : "west";
if (previousWasVertical && vertical >= horizontal * (1 - JOYSTICK_HYSTERESIS)) return dy >= 0 ? "south" : "north"; if (previousWasVertical && vertical >= horizontal * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dy >= 0 ? "south" : "north";
return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north"); return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north");
} }
function joystickRepeatMs(distance, radius) { function avatarRepeatMs(distance, targetRadius) {
if (!Number.isFinite(distance) || !Number.isFinite(radius) || distance <= JOYSTICK_DEAD_ZONE || radius <= JOYSTICK_DEAD_ZONE) return null; if (!Number.isFinite(distance) || !Number.isFinite(targetRadius) || distance <= AVATAR_DEAD_ZONE || targetRadius <= AVATAR_DEAD_ZONE) return null;
const strength = clamp((distance - JOYSTICK_DEAD_ZONE) / (radius - JOYSTICK_DEAD_ZONE), 0, 1); const strength = clamp((distance - AVATAR_DEAD_ZONE) / (targetRadius - AVATAR_DEAD_ZONE), 0, 1);
return Math.round(JOYSTICK_SLOW_REPEAT_MS - (JOYSTICK_SLOW_REPEAT_MS - JOYSTICK_FAST_REPEAT_MS) * strength); return Math.round(AVATAR_SLOW_REPEAT_MS - (AVATAR_SLOW_REPEAT_MS - AVATAR_FAST_REPEAT_MS) * strength);
} }
function joystickVector(dx, dy, radius, previousDirection) { function avatarVector(dx, dy, targetRadius, previousDirection) {
const distance = Math.hypot(dx, dy); const distance = Math.hypot(dx, dy);
const boundedDistance = Math.min(distance, radius); const inputDistance = Math.min(distance, targetRadius);
const ratio = distance > 0 ? boundedDistance / distance : 0; const deflection = Math.min(inputDistance, AVATAR_MAX_DEFLECTION);
const ratio = distance > 0 ? deflection / distance : 0;
return { return {
x: dx * ratio, x: dx * ratio,
y: dy * ratio, y: dy * ratio,
distance: boundedDistance, distance: deflection,
direction: joystickDirection(dx, dy, previousDirection), direction: avatarDirection(dx, dy, previousDirection),
repeatMs: joystickRepeatMs(boundedDistance, radius) repeatMs: avatarRepeatMs(inputDistance, targetRadius)
}; };
} }
class LatestIntentFlow {
constructor(dispatch) {
this.dispatch = dispatch;
this.inFlight = null;
this.latest = null;
}
request(direction) {
if (!direction) return null;
if (this.inFlight) {
this.latest = direction;
return null;
}
const requestId = this.dispatch(direction);
if (requestId) this.inFlight = requestId;
return requestId;
}
complete(requestId) {
if (!requestId || requestId !== this.inFlight) return null;
this.inFlight = null;
const direction = this.latest;
this.latest = null;
return direction ? this.request(direction) : null;
}
release() { this.latest = null; }
disconnect() {
this.inFlight = null;
this.latest = null;
}
}
function playerCellSize(stageWidth, stageHeight, columns, rows) { function playerCellSize(stageWidth, stageHeight, columns, rows) {
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 46; if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 46;
return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72)); return Math.round(clamp(Math.min(stageWidth / (columns + 1), stageHeight / (rows + 1)), 32, 72));
@@ -151,6 +188,7 @@
this.now = typeof now === "function" ? now : () => root.performance.now(); this.now = typeof now === "function" ? now : () => root.performance.now();
this.pending = new Map(); this.pending = new Map();
this.samples = []; this.samples = [];
this.pointerSamples = [];
} }
sent(requestId) { sent(requestId) {
@@ -178,6 +216,14 @@
} }
snapshot() { return this.samples.map(sample => Object.assign({}, sample)); } snapshot() { return this.samples.map(sample => Object.assign({}, sample)); }
pointerPainted(startedAt) {
if (!Number.isFinite(startedAt)) return;
this.pointerSamples.push(this.now() - startedAt);
if (this.pointerSamples.length > LATENCY_SAMPLE_LIMIT) this.pointerSamples.shift();
}
pointerSnapshot() { return this.pointerSamples.slice(); }
} }
function createEnvelope(type, sequence, requestId, payload) { function createEnvelope(type, sequence, requestId, payload) {
@@ -244,7 +290,8 @@
function boot(documentObject, windowObject) { function boot(documentObject, windowObject) {
const byId = id => documentObject.getElementById(id); const byId = id => documentObject.getElementById(id);
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}}; const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, avatar: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
state.moveFlow = new LatestIntentFlow(direction => sendMoveNow(direction));
function showNotice(text, kind) { function showNotice(text, kind) {
const element = byId("notice"); const element = byId("notice");
@@ -272,7 +319,7 @@
byId("connection").dataset.state = connectionState; byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text; byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
if (connectionState !== "online") resetJoystick(); if (connectionState !== "online") resetAvatarControl();
} }
renderWelcomeHeadline(randomLoginHeadline()); renderWelcomeHeadline(randomLoginHeadline());
@@ -416,7 +463,6 @@
if (!(data.passages & 8)) cell.classList.add("wall-w"); if (!(data.passages & 8)) cell.classList.add("wall-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object)); (objects.get(data.index) || []).forEach(object => addObject(cell, object));
const slot = state.session ? state.session.slot : 1; const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.self) addPawn(cell, characterForSlot(slot).symbol, false);
if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true); if (data.index === snapshot.opponent) addPawn(cell, characterForSlot(slot === 1 ? 2 : 1).symbol, true);
maze.appendChild(cell); maze.appendChild(cell);
}); });
@@ -434,6 +480,8 @@
const character = characterForSlot(slot); const character = characterForSlot(slot);
byId("self-slot").textContent = character.symbol; byId("self-slot").textContent = character.symbol;
byId("self-slot").setAttribute("aria-label", character.name); byId("self-slot").setAttribute("aria-label", character.name);
byId("avatar-handle-symbol").textContent = character.symbol;
byId("avatar-control").setAttribute("aria-label", `Тянуть героя ${character.name} для движения`);
byId("self-name").textContent = playerName(); byId("self-name").textContent = playerName();
byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`; byId("identity").className = `identity player-${slot === 1 ? "one" : "two"}`;
byId("match-screen").classList.toggle("player-two-mode", slot === 2); byId("match-screen").classList.toggle("player-two-mode", slot === 2);
@@ -451,7 +499,7 @@
if (Number.isInteger(snapshot.stateSequence)) { if (Number.isInteger(snapshot.stateSequence)) {
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence)); windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
} }
if (snapshot.phase === "paused") { resetJoystick(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); } if (snapshot.phase === "paused") { resetAvatarControl(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result); else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
else { else {
hideModal(); hideModal();
@@ -554,7 +602,7 @@
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
} }
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
resetJoystick(); resetAvatarControl();
byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби"; byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби";
byId("modal-action").hidden = false; byId("modal-action").hidden = false;
} }
@@ -590,9 +638,10 @@
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false); } else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success"); } else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
} else if (message.type === "match_result") { renderResult(message.payload.result); } else if (message.type === "match_result") { renderResult(message.payload.result);
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success"); } else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetAvatarControl(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); feedback(true); } else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); state.moveFlow.complete(message.requestId); feedback(true);
} else if (message.type === "error") { } else if (message.type === "error") {
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); } if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); } if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
showNotice(localError(message.payload.code), "error"); feedback(false); showNotice(localError(message.payload.code), "error"); feedback(false);
@@ -605,48 +654,63 @@
state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`); state.socket = new windowObject.WebSocket(`${scheme}://${windowObject.location.host}/ws`);
state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); }; state.socket.onopen = () => { setConnection("online", "ESP на связи"); const token = windowObject.localStorage.getItem(TOKEN_KEY); if (token) send("restore", {token}, null); };
state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); }; state.socket.onmessage = event => { let message; try { message = JSON.parse(event.data); } catch (_) { showNotice("Сервер прислал нечитаемый пакет.", "error"); return; } handleMessage(message); };
state.socket.onclose = () => { setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); }; state.socket.onclose = () => { state.moveFlow.disconnect(); setConnection("offline", "Связь потеряна"); state.reconnectTimer = windowObject.setTimeout(connect, 1000); };
state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error"); state.socket.onerror = () => showNotice("WebSocket споткнулся. Пробуем снова…", "error");
} }
function move(direction, element) { function move(direction, element) {
if (direction) send("move", {direction}, element || documentObject.querySelector(`[data-direction="${direction}"]`)); if (direction) state.moveFlow.request(direction, element);
} }
function resetJoystick() { function sendMoveNow(direction) {
windowObject.clearTimeout(state.joystick.repeatTimer); return send("move", {direction}, byId("avatar-control"));
state.joystick.pointerId = null; }
state.joystick.direction = null;
state.joystick.repeatTimer = null; function resetAvatarControl() {
state.joystick.repeatMs = null; windowObject.clearTimeout(state.avatar.repeatTimer);
const joystick = byId("joystick"); state.avatar.pointerId = null;
const handle = byId("joystick-handle"); state.avatar.direction = null;
if (joystick) joystick.classList.remove("active"); state.avatar.repeatTimer = null;
state.avatar.repeatMs = null;
state.moveFlow.release();
const control = byId("avatar-control");
const handle = byId("avatar-handle");
if (control) {
control.classList.remove("active");
control.removeAttribute("data-direction");
control.dataset.speed = "stop";
}
if (handle) handle.style.transform = "translate(-50%, -50%)"; if (handle) handle.style.transform = "translate(-50%, -50%)";
} }
function repeatJoystickMove() { function repeatAvatarMove() {
const joystick = state.joystick; const avatar = state.avatar;
if (!joystick.direction || joystick.pointerId == null || !joystick.repeatMs) return; if (!avatar.direction || avatar.pointerId == null || !avatar.repeatMs) return;
move(joystick.direction, byId("joystick")); move(avatar.direction);
joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, joystick.repeatMs); avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, avatar.repeatMs);
} }
function updateJoystick(event) { function updateAvatarControl(event) {
const joystick = byId("joystick"); const pointerStartedAt = windowObject.performance.now();
const rect = joystick.getBoundingClientRect(); const control = byId("avatar-control");
const radius = Math.max(1, Math.min(rect.width, rect.height) / 2 - 33); const rect = control.getBoundingClientRect();
const vector = joystickVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.joystick.direction); const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2);
byId("joystick-handle").style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`; const vector = avatarVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.avatar.direction);
const directionChanged = vector.direction !== state.joystick.direction; const handle = byId("avatar-handle");
state.joystick.direction = vector.direction; handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`;
state.joystick.repeatMs = vector.repeatMs; const directionChanged = vector.direction !== state.avatar.direction;
windowObject.clearTimeout(state.joystick.repeatTimer); state.avatar.direction = vector.direction;
state.joystick.repeatTimer = null; state.avatar.repeatMs = vector.repeatMs;
control.dataset.speed = vector.repeatMs == null ? "stop" : vector.repeatMs <= 220 ? "fast" : "slow";
if (vector.direction) control.dataset.direction = vector.direction;
else control.removeAttribute("data-direction");
windowObject.clearTimeout(state.avatar.repeatTimer);
state.avatar.repeatTimer = null;
if (vector.direction) { if (vector.direction) {
if (directionChanged) move(vector.direction, joystick); if (directionChanged) move(vector.direction);
state.joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, vector.repeatMs); state.avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, vector.repeatMs);
} }
windowObject.requestAnimationFrame(() => state.latency.pointerPainted(pointerStartedAt));
} }
function setMuted(muted) { function setMuted(muted) {
@@ -671,26 +735,27 @@
beginFeedback(); beginFeedback();
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); } const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
}); });
byId("map-shell").addEventListener("touchstart", event => { const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true}); byId("map-shell").addEventListener("touchstart", event => { if (event.target.closest("#avatar-control")) return; const touch = event.changedTouches[0]; state.touchStart = {x: touch.clientX, y: touch.clientY}; }, {passive: true});
byId("map-shell").addEventListener("touchend", event => { if (!state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true}); byId("map-shell").addEventListener("touchend", event => { if (event.target.closest("#avatar-control") || !state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true});
byId("joystick").addEventListener("pointerdown", event => { byId("avatar-control").addEventListener("pointerdown", event => {
event.preventDefault(); event.preventDefault();
beginFeedback(); beginFeedback();
state.joystick.pointerId = event.pointerId; state.avatar.pointerId = event.pointerId;
byId("joystick").classList.add("active"); byId("avatar-control").classList.add("active");
if (byId("joystick").setPointerCapture) byId("joystick").setPointerCapture(event.pointerId); if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId);
updateJoystick(event); updateAvatarControl(event);
}); });
byId("joystick").addEventListener("pointermove", event => { byId("avatar-control").addEventListener("pointermove", event => {
if (event.pointerId === state.joystick.pointerId) updateJoystick(event); if (event.pointerId === state.avatar.pointerId) updateAvatarControl(event);
}); });
const stopJoystick = event => { const stopAvatarControl = event => {
if (event.pointerId === state.joystick.pointerId) resetJoystick(); if (event.pointerId === state.avatar.pointerId) resetAvatarControl();
}; };
byId("joystick").addEventListener("pointerup", stopJoystick); byId("avatar-control").addEventListener("pointerup", stopAvatarControl);
byId("joystick").addEventListener("pointercancel", stopJoystick); byId("avatar-control").addEventListener("pointercancel", stopAvatarControl);
windowObject.addEventListener("blur", resetJoystick); byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetJoystick(); }); windowObject.addEventListener("blur", resetAvatarControl);
documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetAvatarControl(); });
documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button))); documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button)));
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key"))); byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted)); byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
@@ -729,10 +794,10 @@
} }
}); });
connect(); connect();
return {state, handleMessage, move, resetJoystick, latencySamples: () => state.latency.snapshot()}; return {state, handleMessage, move, resetAvatarControl, latencySamples: () => state.latency.snapshot(), pointerFeedbackSamples: () => state.latency.pointerSnapshot()};
} }
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, LatencyTracker, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, AVATAR_TOUCH_TARGET_SIZE, AVATAR_MAX_DEFLECTION, AVATAR_DEAD_ZONE, AVATAR_DIRECTION_HYSTERESIS, AVATAR_SLOW_REPEAT_MS, AVATAR_FAST_REPEAT_MS, LatestIntentFlow, LatencyTracker, directionFromKey, directionFromSwipe, avatarDirection, avatarRepeatMs, avatarVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
if (typeof module !== "undefined" && module.exports) module.exports = api; if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api; root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); }); if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });