diff --git a/README.md b/README.md index 69f27689..58f6ca1a 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ npm start # 启动服务器:http://localhost:3000 | 操作 | 方法 | |---|---| | 购买 / 升级调度中心 / 机变选卡 | 点一次选中,再点一次确认(`D` 升级) | -| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时模型在指针 / 手指下,指针所在的格子就是落点 | +| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时指针 / 手指所在的格子就是落点,能放下时模型直接站在这一格上(不能放下时跟在指针下) | | 调整朝向 | 把干员拖回它自己的格子,再选方向 | | 出售 / 撤退 / 销毁装备 | 点击单位所在的格子 → 底部按钮「出售 +1」「撤退」;也可以把棋盘上的干员拖回整备区撤退。整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区) | | 装备 | 把装备拖到干员所在的格子上(每人 2 件;满了会弹出替换窗口,被替换的一件会被销毁);法术拖到地块上并选方向 | diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f52c7233..abaab8d8 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -74,7 +74,7 @@ - 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**特性写着「可以放置于远程位」的近战干员(钩索师、推击手:歌蕾蒂娅、崖心、见行者,以及自选编队的温蒂)也能放到高台上**,普通和精锐都可以,装什么模组或者不装模组都一样;其他近战干员只能放在地面。站在高台上时照常攻击,但不阻挡敌人。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。 - 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。 - **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。 -- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 +- 拖动时**指针 / 手指所在的格子**就是落点并高亮。这一格可以放下(棋盘或整备区)时,干员模型直接站在这一格上,和官方部署一样:手指在格子里移动时模型不动,移到下一个可放下的格子就站到那一格;指针不在可放下的格子上时(例如在商店栏上),模型跟在指针 / 手指下(脚在指针下方约半格)。鼠标和触屏一样。装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、按你在「干员调配」里给这名干员设的**潜能和练度**(默认潜能 6、精英阶段2-60级)、0 信赖的数值:潜能加成包括部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋按召唤者的潜能;练度把攻击、防御、生命上限单独乘上自持有加成(精英阶段2-60级 ×1.1;召唤物不乘)。补位的替补干员和原型干员没有潜能档,也没有自持有加成(0.2.2;0.2.1 是所有干员一律满潜能、没有自持有加成,GitHub #252)。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 diff --git a/docs/design/client.md b/docs/design/client.md index 458554ba..168756eb 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -22,7 +22,9 @@ view.on('pieceDragStart'|'pieceDrop'|'pieceDragEnd'|'pieceClick'|'pieceHover'|'t // area 'outside' lets the DOM UI decide: released off the grid or over DOM covering the canvas (the shop bar), mouse // and touch alike — there is no drag-to-sell, the piece goes back // the drop target, its highlight and tileHover while dragging = the tile under the POINTER; an item dropped on a - // tile equips the unit standing on it (§18.1) + // tile equips the unit standing on it (§18.1); a dragged unit is drawn standing on that tile while it is a legal + // target — a board tile or a bench slot (render/drag.js dragStandTile), the official deploy drag — and held under + // the pointer elsewhere (DRAG_HOLD_TILES), an item plate on the pointer (§27.62) view.pieceScreenRect(uid) // the drawn body's rect { left, top, right, bottom, width, height, x, y } (tooltips / overlays) view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry) view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen @@ -46,7 +48,7 @@ Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立 A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26). -Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). +Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index 10ee93d3..77dafbc9 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -19,7 +19,7 @@ pull slides, the champagne's bar, closed eyes, late attack clips, the HP-bar rea cues, atlas refreshes, 梓兰's frost) → §27.36–§27.46; the interface (device tips, the settings button, the shop fold, spectators, the top-bar capsule, the empty new shop slot) → §27.47–§27.52; voices → §27.53–§27.54; the statistics page → §27.55; potential and training (潜能 / 练度) and the 干员调配 list → §27.56–§27.59; the 自选 picker's Esc → §27.60; -whole-frame attack intervals and SP costs (PR #402) → §27.61. +whole-frame attack intervals and SP costs (PR #402) → §27.61; the drag snap → §27.62. Decisions of the owner, 2026-10-08 (round 22, from community reports, GitHub and research into the official rules): 潜能 and the 自持有 练度 bonus are set per operator in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (official: the account's @@ -1519,3 +1519,44 @@ Notes for the integrator skipped (the browser suites off); GOLDEN_FULL golden.test 9 / 9; eslint 0 errors (135 warnings, as on the parent). - **Docs**: docs/SIM.md §2 (the countdown, the turrets / icicles, the official 帧对齐 note) and §7.1 (SP); docs/design/geometry-time.md §4. + +### 27.62 A dragged operator stands on the tile it will drop on, as in the official deploy drag (the report in PR #403 by @bubububaoshe — not merged: the pick stays the pointer's tile; the owner's recording of the official game, 2026-10-09) — `render/app.js moveDragVisual`, `render/drag.js dragStandTile` +- **Seen**: while an operator was dragged, the model was held under the pointer — its drawn (lifted) feet + `DRAG_HOLD_TILES` (0.45 tile, §18.1) of its own px per tile below the pointer, solved on the lifted plane (ground + + the 0.3 drag lift) — while the drop target, its highlight, `tileHover` and the direction wheel are the tile under the + pointer. On the prep camera the feet and the shadow landed about 0.7 tile toward the camera: on the tile below the + highlighted one (PR #403's screenshot). Measured on the parent in the mock: the pointer on (9,4) held the model at y + 8.46–8.50 (mouse at 1920×1080, finger on the 844×390 phone), on (11,5) at y 9.98–10.50 (1600×900); in the Final + Assault prep about 0.7 tile off its target. PR #403 moved the pick to the feet instead. That breaks the standing rule + (§18.1: the tile under the pointer decides; restated by the owner on #138, §27.49), still misses by about 0.2 tile (it + samples the ground ray, the model is lifted) and fails models.browser #4.1, so the pick stays and only the drawing + changes. +- **Official**: the owner's recording of the official game (2026-10-09; a deploy drag, the gesture 卫戍协议 shares, research + 09 §1.2): while an operator is dragged onto the field its model is drawn standing on the tile under the touch point. + It does not float with the finger: the finger moving inside the tile leaves the model where it is, the touch point on + that tile at the model's feet or lower body; that tile is lit (with the attack-range stripes), takes the drop, and the + direction wheel opens on it. Over no deployable tile no model is shown on the field. +- **Now**: `moveDragVisual` asks `dragStandTile(pieceDragMove)`, which returns the drop target while it is LEGAL and on + the board or the bench (`{ row, col, bench }`, board space); the unit's world x / y / z are then set to that tile at + once (no tween between tiles) through `boardWorld` — the point the drop tween goes to, with the Final Assault / Hidden + Core prep transform — and it keeps its drag lift. Otherwise — no target, an illegal tile, a temp slot, its own bench + slot, `'outside'` over the shop bar — it is held under the pointer as before, so the player still sees what they carry + (our pieces start on the bench, unlike the official hand card, so "no model off the field" is not taken). Item plates + are unchanged (centred on the pointer). The pick (`pickBoardTile`), the highlight, `tileHover`, the wheel and the drop + are untouched, and mouse and touch share the path (the phone uses the same tile pick). A drop from the snapped place + has nothing left to slide: the lift drops and the wheel opens on the tile. The DOM fallback field + (`ui/fallbackField.js`: a flat grid, its ghost centred on the pointer, so already on its tile) is unchanged. +- [ASSUMED]: a legal bench slot is stood on like a board tile (the recording shows the field only); the 0.3 drag lift + stays while the unit stands (the recording does not settle a lift that small). +- **Golden**: unchanged (presentation only). +- **Tests**: test/render/drag.test.js (+2: `dragStandTile`'s decisions; a drag through the controller over its stubbed + tile pick, mouse and touch — one tile while the pointer moves inside it, the next legal tile at once, none on an + illegal tile, a temp slot, the own bench slot, the shop bar or off the grid, the drop on the tile stood on, a board + piece on its own tile). test/render/models.browser.test.js #4.1 drag (mouse) and touch (phone): over a legal tile the + unit stands on the pointer's tile (its x / y = the tile, on its top, lifted), at four spots of the tile without moving + on screen, at every step of a second leg (standing on the legal tiles, held under the pointer elsewhere), held under + the pointer / finger over the shop bar, standing on a free bench slot; the wheel on the pointer's tile as before. + test/render/fieldview.browser.test.js: in the Final Assault prep (both halves) the dragged unit stands on its target's + boss-field tile, mirrored on the right. Both browser extensions fail on the parent. test/docs-consistency.test.js (the + code calls `dragStandTile`, DESIGN and PLAYING describe it). Docs: docs/PLAYING.md §4, README.md, + docs/design/client.md §9 / §10, the app.js / drag.js / tune.js comments. diff --git a/public/js/render/app.js b/public/js/render/app.js index bcf09699..1c721bd9 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -44,9 +44,10 @@ // clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile: // raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn // body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece -// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item -// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile -// equips that unit. +// drops on the tile under the pointer (render/drag.js). A dragged unit stands on that tile while it is a legal target +// (a board tile or a bench slot: drag.js dragStandTile; the official deploy drag, the owner's recording of 2026-10-09, +// PR #403's report) and is held under the pointer elsewhere — its drawn feet DRAG_HOLD_TILES below it, the pointer on +// its body; an item plate stays centred on the pointer. An item dropped on a unit's tile equips that unit. // Direction step (ui/facingWheel.js, research 09 §1.2): // view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null // view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is @@ -119,7 +120,7 @@ import { SnapshotBuffer, frameTime } from './interp.js'; import { TileField } from './tiles.js'; import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js'; import { FxSystem, ensureDamageFonts } from './fx.js'; -import { createDragController, pieceTile } from './drag.js'; +import { createDragController, pieceTile, dragStandTile } from './drag.js'; import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js'; import { TILE_H, TIER_COLORS, COLORS } from './style.js'; import { loadBoardArt } from './boardArt.js'; @@ -1083,23 +1084,33 @@ export async function createFieldView(host, options = {}) { function moveDragVisual(p) { if (!dragState) return; const v = dragState.view; - // held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the - // scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target - // (p.target, highlighted below) is the tile under the pointer const item = v instanceof ItemView; - const g = cam.unproject(p.x, p.y, 0); - const hold = item ? 0 : DRAG_HOLD_TILES; - const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale; - const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels); - const z = t ? heightAt(t.row, t.col) : 0; - const up = item ? z : z + (v.lift || 0) + (v.hover || 0); - let w = cam.unproject(p.x, p.y + hold * s0, up); - if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w; - if (w) { - v.x = Math.max(-1, Math.min(21, w.x)); - v.y = Math.max(-1, Math.min(19, w.y)); - v.z = z; + // a unit over a legal drop target — the tile under the pointer, on the board or the bench (render/drag.js + // dragStandTile) — stands on it, lifted, as in the official deploy drag (the owner's recording of 2026-10-09): the + // world point the drop tween goes to (boardWorld, the prep field transform applied), set at once, no tween + const stand = item ? null : dragStandTile(p); + if (stand) { + const w = boardWorld(stand.row, stand.col, stand.bench); + v.x = w.x; v.y = w.y; v.z = w.z; + } else { + // otherwise held under the pointer (the player still sees what they carry): a unit with its drawn (lifted) feet + // DRAG_HOLD_TILES of its own px per tile below it (the scale at the pointer's ground, refined once at the feet), an + // item plate centred on it + const g = cam.unproject(p.x, p.y, 0); + const hold = item ? 0 : DRAG_HOLD_TILES; + const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale; + const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels); + const z = t ? heightAt(t.row, t.col) : 0; + const up = item ? z : z + (v.lift || 0) + (v.hover || 0); + let w = cam.unproject(p.x, p.y + hold * s0, up); + if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w; + if (w) { + v.x = Math.max(-1, Math.min(21, w.x)); + v.y = Math.max(-1, Math.min(19, w.y)); + v.z = z; + } } + // the drop target (p.target, highlighted) is the tile under the pointer if (p.target && p.target.area !== 'temp') { const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW; const c = p.target.area === 'board' ? p.target.col : p.target.idx; diff --git a/public/js/render/app/tune.js b/public/js/render/app/tune.js index eda59596..e0fe5e0d 100644 --- a/public/js/render/app/tune.js +++ b/public/js/render/app/tune.js @@ -26,8 +26,9 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']); const DROP_PENDING_MS = 1300; /** - * A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model - * under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it. + * A dragged unit over no legal target is held with its drawn feet this many tiles below the pointer — the pointer on its + * body, the model under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike); over a legal + * target it stands on that tile (render/drag.js dragStandTile). An item plate is centred on the pointer. */ export const DRAG_HOLD_TILES = 0.45; diff --git a/public/js/render/drag.js b/public/js/render/drag.js index e2f8d7a4..af0ee449 100644 --- a/public/js/render/drag.js +++ b/public/js/render/drag.js @@ -11,8 +11,10 @@ // direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe"). // Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the // piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the -// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under -// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike). +// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on +// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer +// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' +// (mouse and touch alike). // // Events emitted through `emit(name, payload)`: // pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes) @@ -80,6 +82,21 @@ export function resolveDrop(p) { return { kind: 'drop', target: slot }; } +/** + * The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while + * it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise + * (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under + * the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the + * finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate. + */ +export function dragStandTile(move, geo = GEO) { + const t = move && move.legal === true ? move.target : null; + if (!t) return null; + if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false }; + if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true }; + return null; +} + /** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */ export function isLegal(canPlace, piece, slot) { if (!slot || slot.area === 'temp' || slot.area === 'outside') return false; diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 820c641f..2372a2c3 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -8,7 +8,8 @@ // local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can // act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail // card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the -// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out +// dragged model held under it (standing on that tile while it is a legal target, §27.62), a single human untimed, the +// strategy draft's one countdown, 机变 two taps, knocked-out // operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5 // (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules, // boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user @@ -294,11 +295,15 @@ test('lost models, live LP, detail card order, static game data (user playtest # }); test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => { - // #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes) + // #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes) — + // standing on the pointer's tile while that is a legal target (§27.62, the official deploy drag) assert.equal(ENEMY_REACH, 0.6); const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8'); const tune = readFileSync(join(ROOT, 'public/js/render/app/tune.js'), 'utf8'); assert.match(tune, /export const DRAG_HOLD_TILES = 0\.45;/); + assert.match(app, /dragStandTile\(p\)/); + assert.match(DESIGN, /`dragStandTile/); + assert.match(PLAYING, /干员模型直接站在这一格上/); assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)'); assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/); assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/); diff --git a/test/render/drag.test.js b/test/render/drag.test.js index 9e8cd2b9..40aa3b4e 100644 --- a/test/render/drag.test.js +++ b/test/render/drag.test.js @@ -1,9 +1,10 @@ -// test/render/drag.test.js — drop target resolution and the pointer state machine (no DOM, fake timers). +// test/render/drag.test.js — drop target resolution, the pointer state machine (no DOM, fake timers) and the tile a +// dragged unit is drawn standing on (dragStandTile; the harness's 100 px grid stands in for the camera's tile pick). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { GEO } from '../../shared/constants.js'; -import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController } from '../../public/js/render/drag.js'; +import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController, dragStandTile } from '../../public/js/render/drag.js'; describe('slots & targets', () => { test('tileSlot classifies hand, temp, board and everything else', () => { @@ -293,3 +294,54 @@ describe('drag controller', () => { } finally { console.error = orig; } }); }); + +// The official deploy drag (the owner's recording of 2026-10-09; the report in PR #403): the dragged unit is drawn +// standing on the tile under the pointer while that is a legal target, and held under the pointer elsewhere. The tile +// is the drag target itself, so the pick, tileHover, the highlight and the drop stay the pointer's tile. +describe('the tile a dragged unit stands on (dragStandTile)', () => { + test('a legal board tile or bench slot is stood on; an illegal tile, a temp slot, no target hold it under the pointer', () => { + assert.deepEqual(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: true }), { row: 10, col: 3, bench: false }); + assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: true }), { row: GEO.HAND_ROW, col: 6, bench: true }); + assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: false }), null, 'an illegal tile'); + assert.equal(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: false }), null, 'the own bench slot / an illegal slot'); + assert.equal(dragStandTile({ target: { area: 'temp', idx: 1 }, legal: true }), null, 'a temp slot is never a target'); + assert.equal(dragStandTile({ target: { area: 'outside', clientX: 1, clientY: 2 }, legal: true }), null); + assert.equal(dragStandTile({ target: null, legal: false }), null, 'off the grid, over the shop bar'); + assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: 1 }), null, 'legal must be true'); + assert.equal(dragStandTile({ target: { area: 'board', row: 9.5, col: 3 }, legal: true }), null); + assert.equal(dragStandTile({ target: { area: 'hand' }, legal: true }), null); + assert.equal(dragStandTile(null), null); + assert.equal(dragStandTile(undefined), null); + assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 2 }, legal: true }, { HAND_ROW: 3 }), { row: 3, col: 2, bench: true }, 'geo is a parameter'); + }); + + test('over the drag path: one tile while the pointer moves inside it, the next legal tile at once, none where no drop lands (mouse and touch)', () => { + for (const pointerType of ['mouse', 'touch']) { + const h = harness({ domCover: true, canPlace: (p, r, c) => !(r === 11 && c === 4) }); + h.c.setEditable(true); + const stand = () => dragStandTile(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]); + const at = (x, y) => { h.ev('pointerMove', x, y, { pointerType }); return stand(); }; + h.ev('pointerDown', 50, 550, { pointerType }); + assert.equal(at(50, 530), null, `${pointerType}: on its own bench slot (no target) it is held under the pointer`); + assert.ok(h.c.dragging); + // the pointer anywhere on (9,4) (x 400–499, y 300–399 in the harness grid): the unit stands on (9,4) the whole time + const inside = [[410, 390], [450, 350], [495, 305], [402, 301]].map(([x, y]) => at(x, y)); + assert.deepEqual(inside, Array(4).fill({ row: 9, col: 4, bench: false }), `${pointerType}: moving inside the tile leaves it there`); + assert.deepEqual(at(450, 299), { row: 10, col: 4, bench: false }, `${pointerType}: the next tile takes it at once`); + assert.equal(at(450, 150), null, `${pointerType}: an illegal tile (11,4)`); + assert.equal(at(550, 450), null, `${pointerType}: a temp slot`); + assert.deepEqual(at(250, 550), { row: GEO.HAND_ROW, col: 2, bench: true }, `${pointerType}: another bench slot`); + assert.equal(at(250, 950), null, `${pointerType}: over the shop bar (DOM over the canvas)`); + assert.equal(at(-20, 300), null, `${pointerType}: off the grid`); + assert.deepEqual(at(350, 250), { row: 10, col: 3, bench: false }); + h.ev('pointerUp', 350, 250, { pointerType }); + assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 }, `${pointerType}: dropped on the tile it stood on`); + // a board piece lifts on its own tile (a legal drop: the direction wheel re-orients it in place) + h.events.length = 0; + h.ev('pointerDown', 450, 250, { pointerType }); + assert.deepEqual(at(470, 230), { row: 10, col: 4, bench: false }, `${pointerType}: a board piece stands on its own tile`); + h.ev('pointerUp', 470, 230, { pointerType }); + assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 }); + } + }); +}); diff --git a/test/render/fieldview.browser.test.js b/test/render/fieldview.browser.test.js index a0d2205b..2d8291ac 100644 --- a/test/render/fieldview.browser.test.js +++ b/test/render/fieldview.browser.test.js @@ -1,7 +1,8 @@ // test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo // (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and // the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2: -// drop targets / directions stay in board space, the right half mirrored) with the round's leader on its spawn tile and +// drop targets / directions stay in board space, the right half mirrored; a dragged unit stands on its target's +// boss-field tile) with the round's leader on its spawn tile and // its red hit tiles beside a range preview (community report #12), damage numbers that never touch, the // automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU. // Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json. @@ -194,6 +195,9 @@ describe('field view features in headless Chrome', { skip }, () => { await page.mouse.move(cx, cy); await page.mouse.down(); for (let i = 1; i <= 12; i++) await page.mouse.move(cx + (target.x - cx) * i / 12, cy + (target.y - cy) * i / 12); + // over the legal target the dragged unit stands on it (the official deploy drag): on that board tile's boss-field + // tile, as the drop will place it — row − 7, the column mirrored on the right half + const stood = await page.evaluate((uid) => { const v = window.__demo.view.debug.views.get('p:' + uid); return { x: v.x, y: v.y, lift: v.lift }; }, info.hand.uid); await page.mouse.up(); await wait(400); const drops = await page.evaluate(() => window.__drops); @@ -222,6 +226,7 @@ describe('field view features in headless Chrome', { skip }, () => { await page.close(); assert.deepEqual(problems, []); assert.deepEqual(drops[0], { area: 'board', row: target.row, col: target.col }, `drop target in board space: ${JSON.stringify(drops)}`); + assert.deepEqual(stood, { x: side === 'R' ? 20 - target.col : target.col, y: target.row - 7, lift: 0.3 }, 'while dragged over it: standing on the target\'s boss-field tile'); assert.deepEqual(dirs, { a: side === 'R' ? 'LEFT' : 'RIGHT', u: 'UP' }); assert.deepEqual(past, { field: true, wallBelow: null, below: null, wallAbove: true, pen: null, pad: true }); }); diff --git a/test/render/models.browser.test.js b/test/render/models.browser.test.js index 89fb737b..8c90c8fd 100644 --- a/test/render/models.browser.test.js +++ b/test/render/models.browser.test.js @@ -9,11 +9,13 @@ // #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board // (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile // nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle -// (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn -// feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its -// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit -// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one -// over the shop bar goes back. +// (allies by their tile, enemies by their ground position or drawn body); a dragged unit stands on the tile under +// the pointer while that is a legal target — wherever the pointer is on the tile, as in the official deploy drag +// (the owner's recording of 2026-10-09; PR #403's report) — and is held under the pointer elsewhere (over the +// shop bar: its drawn feet DRAG_HOLD_TILES below it, the model around it), for a mouse and a finger (phone, +// 844×390); the drop, its tileHover and the direction wheel are the tile under the pointer; equipment dropped on +// a tile equips the unit on it (not the one in front whose head is drawn there); on a phone a release on a bench +// slot lands there and one over the shop bar goes back. // // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png. @@ -315,13 +317,23 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip assert.deepEqual(problems, []); }); - /** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */ + /** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds; + * its world point (wx = col, wy = row, wz) and the height of the board tile under it (hz). */ const ghostAt = (page, uid) => page.evaluate((u) => { const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u); const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null; - return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } }; + return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height }, + wx: v.x, wy: v.y, wz: v.z, hz: R.debug.tiles.heightAt(Math.round(v.y), Math.round(v.x)) }; }, uid); - /** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */ + /** Over a legal target the model stands on the pointer's tile (the official deploy drag): its world point is that + * tile — the tile's top on the board, the bench pad on the bench — and it is lifted. */ + const assertStands = (g, [row, col], label) => { + assert.ok(g.lift > 0, `${label}: lifted`); + assert.ok(Math.abs(g.wx - col) < 1e-6 && Math.abs(g.wy - row) < 1e-6, `${label}: stands on (${row},${col}) (at ${g.wx.toFixed(3)},${g.wy.toFixed(3)})`); + if (row !== 7) assert.ok(Math.abs(g.wz - g.hz) < 1e-6, `${label}: on the tile's top (z ${g.wz} vs ${g.hz})`); + else assert.ok(g.wz > 0, `${label}: on the bench pad (z ${g.wz})`); + }; + /** Elsewhere the model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */ const assertHeld = (g, ptr, label) => { assert.ok(g.lift > 0, `${label}: lifted`); assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`); @@ -331,7 +343,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip } }; - test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s tile', async () => { + test('#4.1 drag (mouse): the unit stands on the pointer\'s tile over a legal target, held under the pointer elsewhere; target, tileHover and the direction wheel are the pointer\'s tile', async () => { const { page, problems } = await open('phase=PREP'); const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; }); await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); }); @@ -340,17 +352,46 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip await page.mouse.move(from.x, from.y); await page.mouse.down(); for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); } await sleep(150); - assertHeld(await ghostAt(page, hand.uid), to, 'mouse'); + const first = await ghostAt(page, hand.uid); + assertStands(first, [9, 4], 'mouse'); assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer'); await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') }); - // the whole way: the model stays under the pointer (sampled at every step of a second drag leg) + // the pointer moving about inside the tile leaves the model where it is (the official recording: the finger moves, + // the model stays on its tile) + for (const [u, v] of [[-0.6, -0.3], [0.6, -0.3], [-0.5, 0.7], [0, 0]]) { // (the nearest strip is hidden by row 8: SPOTS) + const pt = await tileSpot(page, 9, 4, u, v); + await page.mouse.move(pt.x, pt.y); + await sleep(40); + const g = await ghostAt(page, hand.uid); + assertStands(g, [9, 4], `mouse at (${u}, ${v}) of the tile`); + assert.ok(Math.abs(g.x - first.x) < 0.5 && Math.abs(g.y - first.y) < 0.5, `mouse at (${u}, ${v}): the model did not move on screen`); + } + // the whole way: on a legal tile the model stands on the pointer's tile, elsewhere it is under the pointer (sampled + // at every step of a second drag leg; the legal tiles are the view's own 'legal' highlight) + const legal = new Set(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.tiles.highlights.get('legal').tiles.map(([r, c]) => `${r},${c}`))); const leg = await tileSpot(page, 10, 5); + const seen = { stands: 0, held: 0 }; for (let i = 1; i <= 6; i++) { const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 }; await page.mouse.move(pt.x, pt.y); await sleep(40); - assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`); + const tile = await page.evaluate(() => window.__hover.at(-1)); + if (legal.has(`${tile}`)) { seen.stands++; assertStands(await ghostAt(page, hand.uid), tile, `mouse step ${i}`); } + else { seen.held++; assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`); } } + assert.ok(seen.stands > 0, `the leg crosses legal tiles (${JSON.stringify(seen)})`); + // over the shop bar (DOM over the canvas: no target) the model is held under the pointer, so the player still sees + // what they carry + const bar = await page.evaluate(() => { const el = [...document.querySelectorAll('.scard')][2], r = el && el.getBoundingClientRect(); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.3 }; }); + assert.ok(bar, 'premise: a shop card'); + assert.ok(await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.shopbar'), bar.x, bar.y), 'premise: the point is on the shop bar'); + await page.mouse.move(bar.x, bar.y, { steps: 6 }); + await sleep(60); + assert.equal(await page.evaluate(() => window.__hover.at(-1)), null, 'no tile under the shop bar'); + assertHeld(await ghostAt(page, hand.uid), bar, 'mouse over the shop bar'); + await page.mouse.move(to.x, to.y, { steps: 6 }); + await sleep(60); + assertStands(await ghostAt(page, hand.uid), [9, 4], 'mouse back on (9,4)'); await page.mouse.move(to.x, to.y); await sleep(60); await page.mouse.up(); @@ -402,10 +443,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip assert.deepEqual(problems, []); }); - // a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the - // bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar - // goes back - test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', async () => { + // a phone (touch, landscape 844×390): the finger drags the model like the mouse — standing on the finger's tile over a + // legal target, under the finger elsewhere (no lift above the finger); on the bench (the lowest canvas row, the shop + // bar right under it) a release on the slot lands there, one on the shop bar goes back + test('#4.1 touch (phone): stands on the finger\'s tile (under the finger over the shop bar), dropped on the tile under it; a release over the shop bar goes back', async () => { const page = await browser.newPage(); const problems = []; page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); @@ -436,10 +477,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip await page.keyboard.press('Escape'); await sleep(200); } - // a bench operator onto the board: held under the finger, the wheel on the finger's tile + // a bench operator onto the board: standing on the finger's tile, the wheel on that tile const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; }); const to = await tileSpot(page, 9, 4, -0.3, 0.3); - await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') }); + await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertStands(await ghostAt(page, hand.uid), [9, 4], 'touch') }); await page.waitForSelector('.fwheel__dia', { timeout: 3000 }); const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }); const c = await tileSpot(page, 9, 4); @@ -457,9 +498,9 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip return null; }, slot); assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot'); - await touchDrag(await tileSpot(page, unit.row, unit.col), bar); + await touchDrag(await tileSpot(page, unit.row, unit.col), bar, { hold: async () => assertHeld(await ghostAt(page, unit.uid), bar, 'touch over the shop bar') }); assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board'); - await touchDrag(await tileSpot(page, unit.row, unit.col), slot); + await touchDrag(await tileSpot(page, unit.row, unit.col), slot, { hold: async () => assertStands(await ghostAt(page, unit.uid), [7, 5], 'touch on the free bench slot') }); assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there'); await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') }); await page.close();