feat(player): add avatar-centered drag steering

This commit is contained in:
2026-09-09 23:15:00 +03:00
parent 381eacf403
commit 1396bad76d
27 changed files with 307 additions and 451 deletions
+52 -23
View File
@@ -22,25 +22,45 @@ test("keyboard and swipe controls produce equivalent directions", () => {
assert.equal(player.directionFromSwipe(0, 0, 12, 4), null);
});
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"/);