feat(player): add avatar-centered drag steering
This commit is contained in:
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
@@ -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 |
@@ -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}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+11
-9
@@ -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
|
||||
|
||||
+52
-23
@@ -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><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"/);
|
||||
|
||||
+1
-1
@@ -52,11 +52,11 @@
|
||||
<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="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>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
+13
-7
@@ -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) {
|
||||
|
||||
+137
-72
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user