diff --git a/PLANS.md b/PLANS.md index 0a5943d..55306e8 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1561,7 +1561,7 @@ controlled measurement criteria: ## Milestone 017 — Avatar-centered drag steering and latency remediation -**Status:** `NOT_STARTED` +**Status:** `IN_PROGRESS` ### Objective @@ -1652,9 +1652,35 @@ immediate and never approaches the observed 500 ms delay. ### Evidence -Not started. This milestone records the project owner's 2026-09-09 decisions: -the child drags the centered player icon; the icon deflects slightly as the -control handle while the authoritative map moves below it; displacement controls -speed; approximately 100 ms is ideal, 150 ms is acceptable, and delays near -500 ms are unacceptable. Implementation must wait for Milestone 016 to identify -and prioritize the actual latency contributors. +Implementation and automated validation are in progress. This milestone records +the project owner's 2026-09-09 decisions: the child drags the centered player +icon; the icon deflects slightly as the control handle while the authoritative +map moves below it; displacement controls speed; approximately 100 ms is ideal, +150 ms is acceptable, and delays near 500 ms are unacceptable. + +- 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. diff --git a/data/index.html.gz b/data/index.html.gz index b0805d7..e03bc46 100644 Binary files a/data/index.html.gz and b/data/index.html.gz differ diff --git a/docs/3d/maze32-command-chamber/README.md b/docs/3d/maze32-command-chamber/README.md deleted file mode 100644 index 1a9f8b6..0000000 --- a/docs/3d/maze32-command-chamber/README.md +++ /dev/null @@ -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. diff --git a/docs/3d/maze32-command-chamber/__pycache__/build_command_chamber.cpython-313.pyc b/docs/3d/maze32-command-chamber/__pycache__/build_command_chamber.cpython-313.pyc deleted file mode 100644 index 1e1fcec..0000000 Binary files a/docs/3d/maze32-command-chamber/__pycache__/build_command_chamber.cpython-313.pyc and /dev/null differ diff --git a/docs/3d/maze32-command-chamber/build_command_chamber.py b/docs/3d/maze32-command-chamber/build_command_chamber.py deleted file mode 100644 index e2ec934..0000000 --- a/docs/3d/maze32-command-chamber/build_command_chamber.py +++ /dev/null @@ -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() diff --git a/docs/3d/maze32-command-chamber/final-report.json b/docs/3d/maze32-command-chamber/final-report.json deleted file mode 100644 index f3df06c..0000000 --- a/docs/3d/maze32-command-chamber/final-report.json +++ /dev/null @@ -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": [] -} diff --git a/docs/3d/maze32-command-chamber/images/command-table-reference.png b/docs/3d/maze32-command-chamber/images/command-table-reference.png deleted file mode 100644 index ccb9cf0..0000000 Binary files a/docs/3d/maze32-command-chamber/images/command-table-reference.png and /dev/null differ diff --git a/docs/3d/maze32-command-chamber/masks/north-arrival-cutter.json b/docs/3d/maze32-command-chamber/masks/north-arrival-cutter.json deleted file mode 100644 index bd9b08d..0000000 --- a/docs/3d/maze32-command-chamber/masks/north-arrival-cutter.json +++ /dev/null @@ -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" -} diff --git a/docs/3d/maze32-command-chamber/masks/north-arrival-mask.svg b/docs/3d/maze32-command-chamber/masks/north-arrival-mask.svg deleted file mode 100644 index 9ad7d73..0000000 --- a/docs/3d/maze32-command-chamber/masks/north-arrival-mask.svg +++ /dev/null @@ -1 +0,0 @@ -north-arrival: 3.0 m × 3.1 m diff --git a/docs/3d/maze32-command-chamber/masks/opening-contact-sheet.svg b/docs/3d/maze32-command-chamber/masks/opening-contact-sheet.svg deleted file mode 100644 index b99971e..0000000 --- a/docs/3d/maze32-command-chamber/masks/opening-contact-sheet.svg +++ /dev/null @@ -1 +0,0 @@ -Opening mask contact sheetnorth-arrival · door · 3.0 × 3.1 mCutter: rectangular Exact BooleanWall: northPassage depth: 0.8 mDestination: entry apron diff --git a/docs/3d/maze32-command-chamber/milestone-reviews.json b/docs/3d/maze32-command-chamber/milestone-reviews.json deleted file mode 100644 index 1a33a2e..0000000 --- a/docs/3d/maze32-command-chamber/milestone-reviews.json +++ /dev/null @@ -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": ""} -} diff --git a/docs/3d/maze32-command-chamber/openings.json b/docs/3d/maze32-command-chamber/openings.json deleted file mode 100644 index 7c82afa..0000000 --- a/docs/3d/maze32-command-chamber/openings.json +++ /dev/null @@ -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} - ] -} diff --git a/docs/3d/maze32-command-chamber/plan-metadata.json b/docs/3d/maze32-command-chamber/plan-metadata.json deleted file mode 100644 index f14ea36..0000000 --- a/docs/3d/maze32-command-chamber/plan-metadata.json +++ /dev/null @@ -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"] -} diff --git a/docs/3d/maze32-command-chamber/plans/floor-plan.svg b/docs/3d/maze32-command-chamber/plans/floor-plan.svg deleted file mode 100644 index d84938a..0000000 --- a/docs/3d/maze32-command-chamber/plans/floor-plan.svg +++ /dev/null @@ -1 +0,0 @@ -LOWER-ROOM PLAN — 12 m × 12 mcommand tablestationstationstationstation3 m arrival + apron1.4 m circulation ringblack: shell · dashed: paths diff --git a/docs/3d/maze32-command-chamber/plans/north-elevation.svg b/docs/3d/maze32-command-chamber/plans/north-elevation.svg deleted file mode 100644 index 8198bcd..0000000 --- a/docs/3d/maze32-command-chamber/plans/north-elevation.svg +++ /dev/null @@ -1 +0,0 @@ -NORTH WALL ELEVATIONarrival portal: 3.0 m wide × 3.1 m high · actual Boolean apertureceiling: 5.2 m diff --git a/docs/3d/maze32-command-chamber/plans/reflected-ceiling-plan.svg b/docs/3d/maze32-command-chamber/plans/reflected-ceiling-plan.svg deleted file mode 100644 index 2db7922..0000000 --- a/docs/3d/maze32-command-chamber/plans/reflected-ceiling-plan.svg +++ /dev/null @@ -1 +0,0 @@ -REFLECTED CEILING PLAN — 12 m × 12 mcentral suspended beaconceiling light ringouter trim ring diff --git a/docs/3d/maze32-command-chamber/props.json b/docs/3d/maze32-command-chamber/props.json deleted file mode 100644 index 22aed9a..0000000 --- a/docs/3d/maze32-command-chamber/props.json +++ /dev/null @@ -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": []} - ] -} diff --git a/docs/3d/maze32-command-chamber/room-brief.json b/docs/3d/maze32-command-chamber/room-brief.json deleted file mode 100644 index da9fea6..0000000 --- a/docs/3d/maze32-command-chamber/room-brief.json +++ /dev/null @@ -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"} -} diff --git a/docs/3d/maze32-command-chamber/room-layout.json b/docs/3d/maze32-command-chamber/room-layout.json deleted file mode 100644 index 1ab1c07..0000000 --- a/docs/3d/maze32-command-chamber/room-layout.json +++ /dev/null @@ -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} -} diff --git a/docs/M017_LATENCY_REPORT.md b/docs/M017_LATENCY_REPORT.md new file mode 100644 index 0000000..659f94d --- /dev/null +++ b/docs/M017_LATENCY_REPORT.md @@ -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. diff --git a/docs/PLAYER_UI.md b/docs/PLAYER_UI.md index d947d66..9c4d388 100644 --- a/docs/PLAYER_UI.md +++ b/docs/PLAYER_UI.md @@ -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 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: ```json diff --git a/docs/PRE_READER_USABILITY.md b/docs/PRE_READER_USABILITY.md index 6c87868..25f135f 100644 --- a/docs/PRE_READER_USABILITY.md +++ b/docs/PRE_READER_USABILITY.md @@ -21,7 +21,7 @@ child succeeds without reading the Russian support text. | 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 | | Recognize farther vision | eye symbol and map expansion | | Follow the compass | arrow direction and compass icon | diff --git a/docs/UI_STYLE.md b/docs/UI_STYLE.md index 9cdc96b..b062ee3 100644 --- a/docs/UI_STYLE.md +++ b/docs/UI_STYLE.md @@ -64,14 +64,16 @@ are not MVP requirements. - Visible keyboard-focus outlines are not required for the MVP. - On computers, movement works from the keyboard and from an on-screen directional control cluster fixed in the lower-right corner. -- On touch devices, movement uses an always-visible circular joystick at least - 160 by 160 CSS pixels. Its drag stays within the control, returns to center - on release, cancellation, pause, disconnect, and browser blur, and sends - only cardinal movement intentions. -- The joystick has a dead zone and directional hysteresis. Its locally - scheduled repeat interval is bounded and never faster than the server's - movement cooldown. Touch swipes remain supported. Phones do not show a - directional-button cluster. +- On touch devices, the centered player avatar is the movement handle. Its + transparent touch target is at least 96 by 96 CSS pixels and its visible + character remains unobscured. During a drag, it deflects no more than 28 CSS + pixels from center and returns exactly to center on release, cancellation, + pause, disconnect, lost pointer capture, hidden document, and browser blur. +- The avatar drag has a 12 CSS-pixel dead zone and directional hysteresis. Its + bounded repeat cadence changes monotonically from 360 ms at a small + 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, and is positioned closer to the upper edge of the screen than the directional controls. Dropping does not require confirmation. @@ -160,7 +162,7 @@ are not MVP requirements. ## 12. Pre-reader onboarding and usability - 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 Russian text is secondary support for an adult. - The walkthrough provides a replay button and an explicit Russian skip button diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 43e34f5..7bcd42f 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -22,25 +22,45 @@ test("keyboard and swipe controls produce equivalent directions", () => { assert.equal(player.directionFromSwipe(0, 0, 12, 4), null); }); -test("joystick produces only cardinal, bounded movement intentions", () => { - assert.equal(player.joystickDirection(10, 10, null), null); - assert.equal(player.joystickDirection(44, 8, null), "east"); - assert.equal(player.joystickDirection(-8, -44, null), "north"); - assert.equal(player.joystickDirection(42, 38, "east"), "east"); - assert.equal(player.joystickDirection(38, 42, "north"), "south"); - const vector = player.joystickVector(300, 0, 57, null); +test("avatar drag produces only cardinal, bounded movement intentions", () => { + assert.equal(player.avatarDirection(10, 10, null), null); + assert.equal(player.avatarDirection(44, 8, null), "east"); + assert.equal(player.avatarDirection(-8, -44, null), "north"); + assert.equal(player.avatarDirection(42, 38, "east"), "east"); + assert.equal(player.avatarDirection(38, 42, "north"), "south"); + const vector = player.avatarVector(300, 0, 48, null); assert.equal(vector.direction, "east"); - assert.equal(vector.x, 57); + assert.equal(vector.x, player.AVATAR_MAX_DEFLECTION); assert.equal(vector.y, 0); + assert.ok(vector.distance <= player.AVATAR_MAX_DEFLECTION); }); -test("joystick repeat interval is monotonic and remains slower than cooldown", () => { - assert.equal(player.joystickRepeatMs(player.JOYSTICK_DEAD_ZONE, 60), null); - const near = player.joystickRepeatMs(25, 60); - const far = player.joystickRepeatMs(60, 60); +test("avatar cadence is monotonic and remains slower than cooldown", () => { + assert.equal(player.avatarRepeatMs(player.AVATAR_DEAD_ZONE, 48), null); + const near = player.avatarRepeatMs(18, 48); + const far = player.avatarRepeatMs(48, 48); assert.ok(near > far); - assert.ok(far >= player.JOYSTICK_FAST_REPEAT_MS); - assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150); + assert.ok(far >= player.AVATAR_FAST_REPEAT_MS); + 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", () => { @@ -60,6 +80,11 @@ test("latency correlation is bounded and only completes matching authoritative v tracker.sent(`move-${index + 2}`); } 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", () => { @@ -77,16 +102,19 @@ test("onboarding has bounded, replayable steps", () => { 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 script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); - assert.match(script, /addEventListener\("pointerup", stopJoystick\)/); - assert.match(script, /addEventListener\("pointercancel", stopJoystick\)/); - assert.match(script, /addEventListener\("blur", resetJoystick\)/); - assert.match(script, /connectionState !== "online"\) resetJoystick\(\)/); - assert.match(script, /snapshot\.phase === "paused"\) \{ resetJoystick\(\)/); + assert.match(script, /addEventListener\("pointerup", stopAvatarControl\)/); + assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/); + assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/); + assert.match(script, /addEventListener\("blur", resetAvatarControl\)/); + assert.match(script, /connectionState !== "online"\) resetAvatarControl\(\)/); + assert.match(script, /snapshot\.phase === "paused"\) \{ resetAvatarControl\(\)/); assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); + assert.match(html, /id="avatar-control"/); + assert.match(html, /aria-label="Тянуть героя для движения"/); assert.match(script, /onboarding-replay/); assert.match(html, /id="onboarding-dots"[\s\S]*?<\/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, /height: calc\(100dvh - 78px\)/); 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="mute-toggle"/); - assert.match(css, /\.joystick \{[^}]*width: 180px; height: 180px/); - assert.match(script, /resetJoystick/); + assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/); + assert.match(css, /\.avatar-handle \{[^}]*transition: transform/); + assert.match(script, /resetAvatarControl/); assert.match(script, /playerCellSize/); assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/); assert.match(icons, /id="key"/); diff --git a/web/index.html b/web/index.html index a73fe48..64d0032 100644 --- a/web/index.html +++ b/web/index.html @@ -52,11 +52,11 @@
Ждём разрешённый сервером кусочек карты…
+
-
diff --git a/web/player.css b/web/player.css index 8781e55..cf41933 100644 --- a/web/player.css +++ b/web/player.css @@ -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::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 > * { 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-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; } @@ -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; } .map-shell { inset: 82px 0 0; } .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; } .access-controls { top: 88px; right: 8px; } .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; } .map-shell { inset: 64px 0 0; } .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) { diff --git a/web/player.js b/web/player.js index 0f0e865..94c5151 100644 --- a/web/player.js +++ b/web/player.js @@ -8,13 +8,15 @@ const MAX_VIEW_CELLS = 1024; const MAX_VIEW_OBJECTS = 12; const OBSERVER_CELL_SIZE = 8; - const JOYSTICK_DEAD_ZONE = 20; - const JOYSTICK_HYSTERESIS = .18; - const JOYSTICK_SLOW_REPEAT_MS = 360; - const JOYSTICK_FAST_REPEAT_MS = 170; + const AVATAR_TOUCH_TARGET_SIZE = 96; + const AVATAR_MAX_DEFLECTION = 28; + const AVATAR_DEAD_ZONE = 12; + const AVATAR_DIRECTION_HYSTERESIS = .18; + const AVATAR_SLOW_REPEAT_MS = 360; + const AVATAR_FAST_REPEAT_MS = 170; const LATENCY_SAMPLE_LIMIT = 256; 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: "maze", title: "Стена не пускает", text: "Упрись в стену — попробуй другую сторону."}), Object.freeze({icon: "key", title: "Найди ключ", text: "Ключ открывает твой выход."}), @@ -107,36 +109,71 @@ return Math.min(maximum, Math.max(minimum, value)); } - function joystickDirection(dx, dy, previousDirection) { + function avatarDirection(dx, dy, previousDirection) { const horizontal = Math.abs(dx); 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 previousWasVertical = previousDirection === "north" || previousDirection === "south"; - if (previousWasHorizontal && horizontal >= vertical * (1 - JOYSTICK_HYSTERESIS)) return dx >= 0 ? "east" : "west"; - if (previousWasVertical && vertical >= horizontal * (1 - JOYSTICK_HYSTERESIS)) return dy >= 0 ? "south" : "north"; + if (previousWasHorizontal && horizontal >= vertical * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dx >= 0 ? "east" : "west"; + if (previousWasVertical && vertical >= horizontal * (1 - AVATAR_DIRECTION_HYSTERESIS)) return dy >= 0 ? "south" : "north"; return horizontal >= vertical ? (dx >= 0 ? "east" : "west") : (dy >= 0 ? "south" : "north"); } - function joystickRepeatMs(distance, radius) { - if (!Number.isFinite(distance) || !Number.isFinite(radius) || distance <= JOYSTICK_DEAD_ZONE || radius <= JOYSTICK_DEAD_ZONE) return null; - const strength = clamp((distance - JOYSTICK_DEAD_ZONE) / (radius - JOYSTICK_DEAD_ZONE), 0, 1); - return Math.round(JOYSTICK_SLOW_REPEAT_MS - (JOYSTICK_SLOW_REPEAT_MS - JOYSTICK_FAST_REPEAT_MS) * strength); + function avatarRepeatMs(distance, targetRadius) { + if (!Number.isFinite(distance) || !Number.isFinite(targetRadius) || distance <= AVATAR_DEAD_ZONE || targetRadius <= AVATAR_DEAD_ZONE) return null; + const strength = clamp((distance - AVATAR_DEAD_ZONE) / (targetRadius - AVATAR_DEAD_ZONE), 0, 1); + 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 boundedDistance = Math.min(distance, radius); - const ratio = distance > 0 ? boundedDistance / distance : 0; + const inputDistance = Math.min(distance, targetRadius); + const deflection = Math.min(inputDistance, AVATAR_MAX_DEFLECTION); + const ratio = distance > 0 ? deflection / distance : 0; return { x: dx * ratio, y: dy * ratio, - distance: boundedDistance, - direction: joystickDirection(dx, dy, previousDirection), - repeatMs: joystickRepeatMs(boundedDistance, radius) + distance: deflection, + direction: avatarDirection(dx, dy, previousDirection), + 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) { 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)); @@ -151,6 +188,7 @@ this.now = typeof now === "function" ? now : () => root.performance.now(); this.pending = new Map(); this.samples = []; + this.pointerSamples = []; } sent(requestId) { @@ -178,6 +216,14 @@ } 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) { @@ -244,7 +290,8 @@ function boot(documentObject, windowObject) { 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) { const element = byId("notice"); @@ -272,7 +319,7 @@ byId("connection").dataset.state = connectionState; byId("connection-text").textContent = text; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; - if (connectionState !== "online") resetJoystick(); + if (connectionState !== "online") resetAvatarControl(); } renderWelcomeHeadline(randomLoginHeadline()); @@ -416,7 +463,6 @@ if (!(data.passages & 8)) cell.classList.add("wall-w"); (objects.get(data.index) || []).forEach(object => addObject(cell, object)); 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); maze.appendChild(cell); }); @@ -434,6 +480,8 @@ const character = characterForSlot(slot); byId("self-slot").textContent = character.symbol; 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("identity").className = `identity player-${slot === 1 ? "one" : "two"}`; byId("match-screen").classList.toggle("player-two-mode", slot === 2); @@ -451,7 +499,7 @@ if (Number.isInteger(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 { hideModal(); @@ -554,7 +602,7 @@ text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат."; } showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false); - resetJoystick(); + resetAvatarControl(); byId("modal-action").textContent = state.session && state.session.role === "player" && state.session.slot === 1 ? "Новая гонка" : "Вернуться в лобби"; byId("modal-action").hidden = false; } @@ -590,9 +638,10 @@ } else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false); } else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success"); } 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 === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); feedback(true); + } 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); state.moveFlow.complete(message.requestId); feedback(true); } 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 === "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); @@ -605,48 +654,63 @@ 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.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"); } function move(direction, element) { - if (direction) send("move", {direction}, element || documentObject.querySelector(`[data-direction="${direction}"]`)); + if (direction) state.moveFlow.request(direction, element); } - function resetJoystick() { - windowObject.clearTimeout(state.joystick.repeatTimer); - state.joystick.pointerId = null; - state.joystick.direction = null; - state.joystick.repeatTimer = null; - state.joystick.repeatMs = null; - const joystick = byId("joystick"); - const handle = byId("joystick-handle"); - if (joystick) joystick.classList.remove("active"); + function sendMoveNow(direction) { + return send("move", {direction}, byId("avatar-control")); + } + + function resetAvatarControl() { + windowObject.clearTimeout(state.avatar.repeatTimer); + state.avatar.pointerId = null; + state.avatar.direction = null; + 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%)"; } - function repeatJoystickMove() { - const joystick = state.joystick; - if (!joystick.direction || joystick.pointerId == null || !joystick.repeatMs) return; - move(joystick.direction, byId("joystick")); - joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, joystick.repeatMs); + function repeatAvatarMove() { + const avatar = state.avatar; + if (!avatar.direction || avatar.pointerId == null || !avatar.repeatMs) return; + move(avatar.direction); + avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, avatar.repeatMs); } - function updateJoystick(event) { - const joystick = byId("joystick"); - const rect = joystick.getBoundingClientRect(); - const radius = Math.max(1, Math.min(rect.width, rect.height) / 2 - 33); - const vector = joystickVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.joystick.direction); - byId("joystick-handle").style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`; - const directionChanged = vector.direction !== state.joystick.direction; - state.joystick.direction = vector.direction; - state.joystick.repeatMs = vector.repeatMs; - windowObject.clearTimeout(state.joystick.repeatTimer); - state.joystick.repeatTimer = null; + function updateAvatarControl(event) { + const pointerStartedAt = windowObject.performance.now(); + const control = byId("avatar-control"); + const rect = control.getBoundingClientRect(); + const radius = Math.max(AVATAR_DEAD_ZONE + 1, Math.min(rect.width, rect.height) / 2); + const vector = avatarVector(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2), radius, state.avatar.direction); + const handle = byId("avatar-handle"); + handle.style.transform = `translate(calc(-50% + ${vector.x}px), calc(-50% + ${vector.y}px))`; + const directionChanged = vector.direction !== state.avatar.direction; + state.avatar.direction = vector.direction; + 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 (directionChanged) move(vector.direction, joystick); - state.joystick.repeatTimer = windowObject.setTimeout(repeatJoystickMove, vector.repeatMs); + if (directionChanged) move(vector.direction); + state.avatar.repeatTimer = windowObject.setTimeout(repeatAvatarMove, vector.repeatMs); } + windowObject.requestAnimationFrame(() => state.latency.pointerPainted(pointerStartedAt)); } function setMuted(muted) { @@ -671,26 +735,27 @@ beginFeedback(); 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("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("joystick").addEventListener("pointerdown", event => { + 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 (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("avatar-control").addEventListener("pointerdown", event => { event.preventDefault(); beginFeedback(); - state.joystick.pointerId = event.pointerId; - byId("joystick").classList.add("active"); - if (byId("joystick").setPointerCapture) byId("joystick").setPointerCapture(event.pointerId); - updateJoystick(event); + state.avatar.pointerId = event.pointerId; + byId("avatar-control").classList.add("active"); + if (byId("avatar-control").setPointerCapture) byId("avatar-control").setPointerCapture(event.pointerId); + updateAvatarControl(event); }); - byId("joystick").addEventListener("pointermove", event => { - if (event.pointerId === state.joystick.pointerId) updateJoystick(event); + byId("avatar-control").addEventListener("pointermove", event => { + if (event.pointerId === state.avatar.pointerId) updateAvatarControl(event); }); - const stopJoystick = event => { - if (event.pointerId === state.joystick.pointerId) resetJoystick(); + const stopAvatarControl = event => { + if (event.pointerId === state.avatar.pointerId) resetAvatarControl(); }; - byId("joystick").addEventListener("pointerup", stopJoystick); - byId("joystick").addEventListener("pointercancel", stopJoystick); - windowObject.addEventListener("blur", resetJoystick); - documentObject.addEventListener("visibilitychange", () => { if (documentObject.hidden) resetJoystick(); }); + byId("avatar-control").addEventListener("pointerup", stopAvatarControl); + byId("avatar-control").addEventListener("pointercancel", stopAvatarControl); + byId("avatar-control").addEventListener("lostpointercapture", stopAvatarControl); + 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))); byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key"))); byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted)); @@ -729,10 +794,10 @@ } }); 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; root.Maze32Player = api; if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });