:root{color-scheme:light;--ink:#293f3d;--muted:#657773;--green:#2d745f;--line:#d5e0dc;--paper:#f7faf8;--accent:#d77c87;font-family:"Yuanti SC","PingFang SC",system-ui,sans-serif;color:var(--ink);background:#eef4f1;font-synthesis:none}
*{box-sizing:border-box;letter-spacing:0}body{margin:0}button,input{font:inherit}button{cursor:pointer;color:inherit}button:disabled{cursor:default;opacity:.5}button:focus-visible,input:focus-visible{outline:3px solid #d77c87;outline-offset:4px}button{touch-action:manipulation}button svg{width:20px;height:20px;flex:none}button{display:inline-flex;align-items:center;justify-content:center;gap:8px}button[hidden],[hidden]{display:none!important}
.app{max-width:1248px;margin:auto;padding:24px 28px 18px}.topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:20px}.brand{display:flex;align-items:center;gap:12px}.brand-mark{width:40px;height:40px;background:#e4ede7;display:grid;place-items:center;border-radius:50%;color:var(--green)}h1{font-size:22px;line-height:1.25;margin:0;font-weight:800}.brand small{display:block;font-size:11px;color:var(--muted);margin-top:5px}.tools{display:flex;gap:8px}.icon-button{width:42px;height:42px;border:1px solid var(--line);background:#ffffffb8;border-radius:50%;position:relative}.icon-button:hover{background:white;color:var(--green)}[data-tip]:hover:after{content:attr(data-tip);position:absolute;right:0;top:calc(100% + 8px);z-index:20;white-space:nowrap;background:var(--ink);color:white;padding:6px 9px;border-radius:4px;font-size:12px;pointer-events:none}
.stage{position:relative;width:100%;aspect-ratio:2/1;overflow:hidden;background:#cfe4dd;border-radius:8px;isolation:isolate}canvas{display:block;width:100%;height:100%;object-fit:contain}.map-screen{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;padding:28px 32px;display:flex;flex-direction:column;background:linear-gradient(180deg,#f8fbf4ba 0%,#f8fbf425 38%,#eff6eef0 100%)}.map-heading{display:flex;justify-content:space-between;align-items:start;gap:16px}.chapter-eyebrow{font-size:11px;font-weight:750;color:#4c7169}.map-heading h2{font-size:32px;margin:5px 0 7px}.map-heading p{font-size:13px;margin:0;color:#546b62}.progress-label{font-size:12px;color:#526b62}.chapter-tabs{display:flex;gap:5px;margin-top:18px;align-self:flex-start;border-bottom:1px solid #799b8659}.chapter-tab{border:0;background:transparent;padding:9px 13px;color:#526c60;font-size:12px;border-bottom:3px solid transparent}.chapter-tab[aria-selected=true]{color:var(--green);font-weight:800;border-color:var(--green)}
.level-path{flex:1;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));align-items:center;gap:10px;min-height:130px;margin:5px 0 12px}.level-node{background:transparent;border:0;flex-direction:column;position:relative;gap:10px;min-width:0;font-size:12px;padding:7px 0}.level-node:nth-child(even){transform:translateY(21px)}.level-node:before{content:'';position:absolute;width:calc(100% + 18px);height:1px;border-top:2px dashed #79997b;left:50%;top:37px;z-index:-1}.level-node:last-child:before{display:none}.node-number{display:grid;place-items:center;width:55px;height:55px;background:#f9fcf6;border:2px solid #a5baaa;border-radius:50%;font-size:19px;font-weight:800;box-shadow:0 4px 0 #49674621;transition:transform .15s}.level-node[aria-current=true] .node-number{background:var(--green);border-color:var(--green);color:white;outline:5px solid #fff8}.level-node.done .node-number{border-color:var(--green);color:var(--green)}.level-node:hover .node-number{transform:translateY(-3px)}.node-name{font-weight:750;text-shadow:0 1px 6px white;max-width:100%;overflow-wrap:anywhere}.map-bottom{display:flex;justify-content:space-between;align-items:end;gap:12px}.selected-level{font-size:17px;font-weight:800;margin-bottom:4px}.selected-detail{font-size:12px;color:var(--muted)}.primary,.secondary{min-height:44px;border:0;padding:10px 20px;border-radius:6px;font-weight:700;font-size:14px}.primary{background:var(--green);color:white}.primary:hover{background:#225f4e}.secondary{background:#e7efeb;color:var(--ink)}.map-actions{display:flex;gap:8px}
.gamebar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0}.level-caption{display:flex;gap:10px;align-items:center;min-width:0}.level-caption strong{font-size:14px}.level-caption small{font-size:11px;color:var(--muted)}.number-badge{display:grid;place-items:center;background:var(--green);color:white;width:30px;height:30px;border-radius:50%;font-size:12px;font-weight:750;flex:none}.character-controls{display:flex;gap:6px;align-items:center}.character-button{border:1px solid var(--line);border-radius:6px;background:white;padding:7px 10px;font-size:12px;height:38px}.character-button[aria-pressed=true]{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green);background:#e4efe9}.link-status{font-size:11px;color:var(--muted);margin:0 7px}.footer{display:flex;justify-content:space-between;gap:12px;padding-top:14px;font-size:11px;color:var(--muted);line-height:1.6}.footer p{margin:0}.hint-line{padding:9px 12px;background:#ffffffbb;border-left:3px solid var(--green);font-size:13px;line-height:1.6;margin-bottom:8px}.touch-controls{display:none;justify-content:space-between;align-items:center;gap:10px;padding:16px 0;touch-action:none;user-select:none}.touch-group{display:flex;gap:9px;align-items:center}.touch-key{width:var(--touch-key-w);height:var(--touch-key-h);background:#fff;border:1px solid #b3c9bd;border-radius:50%;box-shadow:0 3px 0 #c5d8cd;touch-action:none}.touch-key.jump{background:var(--green);color:white;width:var(--touch-jump-w);height:var(--touch-jump-h)}.touch-key:active,.touch-key.pressed{transform:translateY(2px);box-shadow:none;background:#dfeee5}.touch-key.jump:active{background:#225f4e}
dialog{border:1px solid #d7e3dc;border-radius:8px;padding:26px;width:min(440px,calc(100vw - 32px));max-height:calc(var(--app-height, 100vh) - 32px);overflow:auto;color:var(--ink);background:#f8fbf9;box-shadow:0 22px 90px #173c3433}dialog::backdrop{background:#193e3a70;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.dialog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.dialog-head h2{font-size:21px;margin:0}.dialog-head .icon-button{width:34px;height:34px}.dialog-actions{display:flex;flex-direction:column;gap:10px}.setting{display:grid;grid-template-columns:80px 1fr 38px;gap:12px;align-items:center;margin:22px 0;font-size:13px}.setting input{width:100%;accent-color:var(--green)}.setting output{color:var(--muted);text-align:right;font-size:11px}.toggle-setting{display:flex;align-items:center;justify-content:space-between;font-size:13px;margin:22px 0}.toggle-setting input{width:18px;height:18px;accent-color:var(--green)}.costumes{display:grid;grid-template-columns:1fr 1fr;gap:12px}.costume-button{padding:10px;min-height:125px;flex-direction:column;border:1px solid var(--line);border-radius:6px;background:#edf4ef;font-size:13px}.costume-button[aria-pressed=true]{border:2px solid var(--green)}.portrait{width:64px;height:64px;object-fit:contain}.record-stats{display:flex;justify-content:space-around;text-align:center;margin:24px 0}.record-stats strong{display:block;font-size:25px;margin-bottom:6px}.record-stats span{font-size:12px;color:var(--muted)}.dialog-story{line-height:1.7;font-size:14px;color:var(--muted)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
body[data-screen=select] .game-only,body[data-screen=play] .select-only{display:none!important}.app:fullscreen{background:#eef4f1;max-width:none;display:flex;flex-direction:column;justify-content:center;padding:12px 24px}.app:fullscreen .stage{max-height:calc(var(--app-height, 100vh) - 150px);width:auto;aspect-ratio:2/1}.app:fullscreen .footer{display:none}
@media(pointer:coarse){body[data-screen=play] .touch-controls{display:flex}}
body.touch-device[data-screen=play] .touch-controls{display:flex}
@media(max-width:700px){.app{padding:15px 12px}.topbar{margin-bottom:13px;gap:8px}.brand{gap:8px}.brand-mark{display:none}h1{font-size:18px}.brand small{font-size:10px}.tools{gap:5px}.icon-button{width:35px;height:35px}.icon-button svg{width:17px;height:17px}.map-screen{padding:20px}.map-heading h2{font-size:27px}.map-heading p{font-size:12px}.chapter-tabs{width:100%;gap:0;margin-top:16px}.chapter-tab{padding:9px 0;flex:1;font-size:11px}.stage{border-radius:6px}body[data-screen=select] .stage{aspect-ratio:auto;height:570px}body[data-screen=select] canvas{object-fit:cover}.level-path{grid-template-columns:1fr;gap:0;margin:12px 0}.level-node{flex-direction:row;justify-content:flex-start;padding:3px 12px;gap:15px}.level-node:nth-child(even){transform:translateX(24px)}.level-node:before{left:35px;top:42px;width:1px;height:30px;border-top:0;border-left:2px dashed #79997b}.node-number{width:43px;height:43px;font-size:16px;flex:none}.node-name{font-size:13px}.map-bottom{align-items:stretch;flex-direction:column;gap:12px}.map-actions{width:100%}.map-actions button{flex:1}.selected-level{font-size:15px}.selected-detail{font-size:11px}.gamebar{flex-wrap:wrap;gap:10px}.character-controls{width:100%;justify-content:space-between}.footer{font-size:10px;flex-direction:column;gap:3px}.touch-group{gap:7px}.touch-controls{padding:14px 0}.link-status{margin:0 3px}.hint-line{font-size:12px}.map-heading .progress-label{font-size:10px;white-space:nowrap}.selected-level,.node-name{overflow-wrap:anywhere}}
@media(max-height:530px) and (orientation:landscape){.app{padding:6px 12px;max-width:960px}.topbar{margin-bottom:6px}.brand small,.footer,.level-caption small{display:none}.gamebar{min-height:40px;padding:5px 0}.stage{max-height:calc(var(--app-height, 100vh) - 130px);width:auto;aspect-ratio:2/1;margin:auto}.touch-controls{position:fixed;inset:auto 20px 10px;z-index:4;padding:0;pointer-events:none}.touch-group{pointer-events:auto}.touch-key{opacity:.87}.app:fullscreen .stage{max-height:calc(var(--app-height, 100vh) - 95px)}}
@media(max-height:530px) and (orientation:landscape) and (pointer:coarse){body[data-screen=play] .gamebar{display:none!important}body[data-screen=play] .stage{max-height:calc(var(--app-height, 100vh) - 130px);margin-bottom:70px}.touch-controls{inset:auto 20px 8px}.touch-key{background:#fff;opacity:1}.touch-key.jump{background:var(--green)}.app:fullscreen .stage{max-height:calc(var(--app-height, 100vh) - 130px)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important;animation:none!important}}
@media(max-height:530px) and (orientation:landscape){body[data-screen=select] .stage{max-height:none;width:100%;height:430px;aspect-ratio:auto}body[data-screen=select] .map-screen{padding:16px 24px}body[data-screen=select] .map-heading h2{font-size:24px}body[data-screen=select] .level-path{min-height:100px}body[data-screen=select] .map-heading p{font-size:12px}}
@media(max-width:360px){.topbar{flex-wrap:wrap}.tools{margin-left:auto}.touch-group{gap:4px}.touch-controls{gap:6px}.map-screen{padding:16px}}
.level-node.done[aria-current=true] .node-number{color:white}
@media(max-height:530px) and (orientation:landscape){body.touch-device[data-screen=play] .gamebar{display:none!important}body.touch-device[data-screen=play] .stage{max-height:calc(var(--app-height, 100vh) - 130px);margin-bottom:70px}}
html[data-motion=reduced] *,html[data-motion=reduced] *:before,html[data-motion=reduced] *:after{transition:none!important;animation:none!important}
.footer a{color:inherit;text-underline-offset:3px}.music-credits{max-width:760px;margin:40px auto;padding:0 24px;line-height:1.8}.music-credits h1{margin-top:24px}.music-credits li{margin:12px 0}.music-credits a{color:var(--green);overflow-wrap:anywhere}
.asset-status{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px;background:#eef4f1f5;text-align:center;font-size:14px}.asset-status p{margin:0}

/* Chrome 61 基线：它不认 aspect-ratio ✗ .stage 高度会变成 0 ✗ 画布也就 0 高（游戏整个看不见）。
   reference 要求的做法：百分比 padding-top 比例盒 + 内容绝对定位 ✗ 用 @supports 精确查询 ✓
   @supports 本身 Chrome 28+ 就有 ✓ 所以这条在老内核上会生效、在新内核上会被跳过 ✓ */
@supports not (aspect-ratio: 2 / 1){
  .stage{height:0;padding-top:50%}
  .stage>canvas{position:absolute;top:0;left:0;width:100%;height:100%}
  .stage>.asset-status{position:absolute;top:0;left:0}
}
/* 按钮样式的"链接"：原来这里是 <a target=_blank> ✗ 端能力规范禁止弹新窗口 */
.link-button{background:none;border:0;padding:0;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
.credits-body{max-height:60vh;overflow:auto;text-align:left;line-height:1.7}
.credits-body ul{padding-left:1.2em;margin:8px 0}
.credits-body p{margin:8px 0}
.license-text{font-size:11px;line-height:1.5;white-space:pre-wrap;opacity:.75;margin:8px 0 0}

/* ── 手机横屏适配 ──
   原来 .stage 有 max-height: calc(100vh - 130px) ✗ 横屏 390 高的机器只剩 260 ✗
   而画布是 2:1 ✗ 于是宽度被压到 520 ✗ 视口 844 里浪费了 324px（38%）✓
   横屏时页脚本来就隐藏（实测底边 0）✗ 那 130px 的预留是多余的 ✓
   修法：横屏让舞台吃满高度；触摸键改为**悬浮覆盖**，不再占布局空间 ✓ */
@media (max-height:530px) and (orientation:landscape){
  /* 横屏：整页不滚动，顶栏压扁到 40px 左右，舞台把剩下的高度吃满。
     舞台必须按 2:1 反推宽度 ✗ 所以只能从高度入手 ✓ */
  html,body{overflow:hidden;height:100%}
  .app{padding:4px 8px 0}
  .topbar{margin-bottom:4px;min-height:0}
  .topbar .sub,.footer{display:none}
  body[data-screen=play] .stage,body.touch-device[data-screen=play] .stage,body[data-screen=select] .stage{
    max-height:calc(var(--app-height, 100vh) - 46px);height:auto}
  .touch-controls{position:fixed;left:0;right:0;bottom:0;z-index:30;
    padding:4px 12px calc(4px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
    background:linear-gradient(to top,rgba(0,0,0,.28),rgba(0,0,0,0))}
  /* 按键透明化：去掉白底/绿底/边框和立体投影 ✓
     图标用**双层投影**（白晕 + 暗边）✗ 这样在浅色草地和深色泥土上都看得清 ✓
     按压反馈改成缩放+降透明度（没有底色也能看出按下了 ✓） */
  .touch-controls{background:none;box-shadow:none}
  /* 按键：灰色半透明 ✓ 保留一点玻璃感，既不挡画面又能看出按钮范围 */
  .touch-key,.touch-key.jump{
    background:rgba(96,106,101,.42);border-color:rgba(255,255,255,.38);
    box-shadow:0 1px 6px rgba(0,0,0,.18);opacity:1;color:#fff}
  .touch-key:active,.touch-key.pressed{
    background:rgba(96,106,101,.66);box-shadow:none;transform:scale(.94)}
  .touch-key svg,.touch-key i{
    width:30px;height:30px;
    filter:drop-shadow(0 0 2px rgba(255,255,255,.95)) drop-shadow(0 1px 2px rgba(0,0,0,.5))}
  .touch-key.jump svg,.touch-key.jump i{width:34px;height:34px}
  .touch-key:active,.touch-key.pressed{transform:scale(.9);opacity:.65}
}

/* ── 默认"视觉全屏" ──
   端能力规范：requestFullscreen 由容器统一管理 ✗ 用 CSS 沉浸式布局实现视觉全屏 ✓
   做法：顶栏/页脚不再占布局高度，改成悬浮在画布之上 ✗ 画布吃满整个视口 ✓ */
body[data-screen=play] .app{padding:0;max-width:none}
body[data-screen=play] .topbar{
  position:fixed;top:0;left:0;right:0;z-index:40;margin:0;padding:8px 14px;
  background:none;pointer-events:none}
/* 顶栏不再铺白底 ✗ 否则黑色半透明按键会被冲淡成灰的 ✓
   标题改用白色描边保证在任意画面上都读得清 ✓ */
body[data-screen=play] .topbar .brand,body[data-screen=play] .topbar .tools{pointer-events:auto}
body[data-screen=play] .topbar h1{
  text-shadow:0 0 7px rgba(255,255,255,.95),0 0 3px rgba(255,255,255,.95),0 1px 2px rgba(255,255,255,.9)}
body[data-screen=play] .footer{display:none}
body[data-screen=play] .stage{border-radius:0}
body[data-screen=play] .stage,body.touch-device[data-screen=play] .stage{
  max-height:var(--app-height, 100vh)}

/* ── 触摸键统一为"灰色半透明" ──
   原来基础样式是 background:#fff / .jump 是绿色 ✗ 在横屏悬浮时会挡画面 ✓
   统一成灰色半透明 + 白色图标 + 一圈浅边 ✗ 任何背景上都看得清 ✓ */
.touch-key,.touch-key.jump{
  background:rgba(96,106,101,.42);border:1px solid rgba(255,255,255,.38);
  box-shadow:0 1px 6px rgba(0,0,0,.18);color:#fff}
.touch-key svg,.touch-key i{color:#fff}
.touch-key:active,.touch-key.pressed{background:rgba(96,106,101,.66);box-shadow:none;transform:scale(.94)}
.touch-key.jump:active{background:rgba(96,106,101,.66)}

/* ── 顶栏按键：黑色半透明 ──
   基础 .icon-button 是 background:#ffffffb8（白色半透明）✗
   但 .icon-button 也被对话框的关闭按钮复用 ✗ 所以这里只改**顶栏里**的那些 ✓ */
.topbar .icon-button{
  background:rgba(0,0,0,.42);border:1px solid rgba(255,255,255,.30);
  color:#fff;box-shadow:0 1px 6px rgba(0,0,0,.18)}
.topbar .icon-button svg{color:#fff;stroke:#fff}
.topbar .icon-button:hover{background:rgba(0,0,0,.6);color:#fff}
.topbar .icon-button:active{background:rgba(0,0,0,.72);transform:scale(.94)}

/* ── 沉浸模式（容器里没有 requestFullscreen 时的降级）──
   按全屏键 / U 键：隐藏顶栏和触摸键，只留画面 ✓ 再按一次恢复 ✓ */
/* 沉浸模式只收顶栏 ✗ **绝不能藏触摸键** ✓
   （第一版把 .touch-controls 也藏了 ✗ 结果点全屏后按键消失、游戏没法玩 ✓） */
/* 沉浸模式：只留「沉浸全屏」那一个按钮 ✗ 其余收起来 ✓
   第一版把整个顶栏藏了 ✗ 结果连退出按钮一起没了 ✓ 手机上没键盘，玩家就卡在沉浸里出不来 ✓ */
body.immersive .topbar .brand,
body.immersive .topbar .tools .icon-button:not([data-action="fullscreen"]){
  opacity:0;pointer-events:none;transition:opacity .25s}
body.immersive .topbar{background:none;pointer-events:none}
body.immersive .topbar .tools{pointer-events:auto}
body.immersive .topbar .tools .icon-button[data-action="fullscreen"]{opacity:.9}
body.immersive .touch-controls{opacity:1;pointer-events:auto}
body.immersive .stage{border-radius:0}
.export-text{width:100%;min-height:180px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;border:1px solid var(--line);border-radius:6px;padding:10px;resize:vertical}
.export-tip{font-size:13px;color:var(--muted);margin:4px 0 8px}

/* ── 横屏安全区：左右也要避让 ──
   刘海/圆角在横屏时位于**左右两侧** ✗ 我原来只处理了 bottom ✓
   实测结果（模拟刘海）：iPhone 15 横刘海 59px ✗ 而按钮左缘只有 12px ✗ 被压住 ✓
   规范写法：var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) ✓ 配合 viewport-fit=cover ✓ */
/* 必须用和沉浸模式同优先级的复合选择器 ✗ 否则被 body[data-screen=play] .topbar
   里的 padding:8px 14px 冲掉（这个坑第三次踩了 ✓） */
.topbar,
body[data-screen=play] .topbar{
  padding-left:calc(var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) + 14px);
  padding-right:calc(var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) + 14px);
  padding-top:calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px)}
.touch-controls{
  padding-left:var(--safe-area-inset-left, env(safe-area-inset-left, 16px));
  padding-right:var(--safe-area-inset-right, env(safe-area-inset-right, 16px));
  padding-bottom:var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 8px))}

/* ── 竖屏提示 ──
   页面改不了屏幕方向（screen.orientation.lock 需要原生全屏 ✗ 而它是禁用 API）✓
   显示条件也交给 JS 的 data-rotate-hint ✗ 不再用媒体查询 ✗ 理由见下面「强制横屏」段 ✓
   提示文案里会带上视口尺寸和 pointer 类型 ✗ 玩家截一张图我就能知道容器报了什么 ✓ */
.rotate-hint{display:none}
body[data-screen=play][data-rotate-hint] .rotate-hint{
  display:block;position:fixed;left:50%;transform:translateX(-50%);
  bottom:calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + 8px);
  z-index:35;padding:7px 14px;border-radius:999px;font-size:12px;
  background:rgba(0,0,0,.55);color:#fff;pointer-events:none;white-space:nowrap}

/* ── 强制横屏（触发条件是 JS 挂的 data-force-landscape ✗ 不是媒体查询）──
   原理：竖屏时把整个应用**旋转 90°** 并按 100vh×100vw 摆放 ✗
   于是画面以横屏形态渲染并吃满整屏 ✓ 玩家把手机横过来就得到完整横屏游戏 ✓
   ⚠⚠ 开关绝不能交给媒体查询 ✗ 这是踩得最狠的一个坑：
     第一版写的是 @media (orientation:portrait) and (pointer:coarse) ✗
     真机（小红书容器）反馈「画面还是竖着的，什么都没转」✗
     因为容器 WebView / iframe 里 **pointer:coarse 常常不成立**（报 fine）✗
     这一条不成立 ✗ 旋转 / overflow / 按键定位 / 按键显示**一起失效** ✓
     现在：JS 量视口（ui.js 的 syncForceLandscape）✗ 竖的就挂属性 ✗ CSS 只认属性 ✓
     附带好处：coarse 不成立时触摸键原本也会被 @media(pointer:coarse) 藏掉 ✗ 这里一并兜住 ✓ */
html[data-force-landscape],html[data-force-landscape] body{height:100%;overflow:hidden}
/* 旋转范围是**所有屏幕** ✗ 不光是游戏画面 ✗
   真机反馈「旅途选关这个页面还不行」✗ 因为我一开始只写了 [data-screen=play] ✗
   选关页是 data-screen=select ✗ 于是游戏转了、选关页还是竖的 ✓ */
body[data-force-landscape] .app{
  position:fixed;top:0;left:100vw;
  width:var(--app-height, 100vh);height:100vw;
  max-width:none;margin:0;padding:0;overflow:hidden;
  transform:rotate(90deg);transform-origin:top left}
/* 选关页是一长条列表 ✗ 而上面那条是 overflow:hidden ✗
   直接转过来只能看见最前面几关 ✗ 后面十几关**根本滚不到** ✗ 所以必须放开滚动 ✓
   （游戏画面仍然保持 overflow:hidden ✗ 那边绝对不能滚 ✓） */
body[data-force-landscape][data-screen=select] .app{
  overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch}
/* ⚠ 这里**不再给画面留安全区内边距** ✗ 两个要求是互相冲突的：
     「避让灵动岛」要求左右留边 ✗ 「不要留白」要求铺满 ✗ 二者不可能同时满足 ✓
   真机反馈「两侧画面有空白，是不是想办法延伸下画面」→ 优先级更高的显然是**不留白** ✓
   所以改成业界通行做法：**画面铺满全屏 ✗ 只有控件避让安全区** ✓
     · 画面：铺满 844×390 ✗ 需要用 object-fit:cover 把 2:1 的内容塞进 2.16:1 ✗
       上下各裁 16px（各半行 ✗ 正好是天花板/地板的装饰边 ✗ 玩法内容一行不动 ✓）
     · 控件：顶栏和触摸键仍旧避让灵动岛/home 条（见下面两条规则的 padding）✓
   灵动岛本体只是左侧中间一小块药丸 ✗ 压在画面左缘的墙面上 ✗ 不挡角色和出口 ✓ */
/* 顶栏必须**脱离文档流浮在画面上** ✗
   真机反馈：「画面明显偏小靠上，没有占满全屏」✗ 根因有两个：
     ① 我上一版把画面高度写成 calc(100vw - 62px) ✗ 砍掉 62px 留给顶栏 ✗ 画面白白小 16% ✗
     ② margin:auto 只能水平居中（块级布局里垂直 auto 边距无效）✗ 所以画面还贴在顶上 ✓
   现在：顶栏绝对定位浮上去 ✗ 画面吃满整个高度 ✗ 居中只剩水平方向要管 ✓
   （顶栏底色去掉 ✗ 按钮本身就是半透明黑圆 ✗ 压在天空上照样看得清 ✓）
   ⚠ 下面的 padding 方向必须**全部重映射** ✗ 这是「右上角菜单栏位置太靠下」的根因：
     本地左 = 物理顶（灵动岛）✗ 本地右 = 物理底（home 条）
     本地上 = 物理右 ✗ 本地下 = 物理左
     上一版没重映射 ✗ 于是灵动岛的 59px 被当成 padding-top ✗ 把右上角菜单整整往下推 59px ✓ */
body[data-force-landscape][data-screen=play] .topbar,
body.touch-device[data-force-landscape][data-screen=play] .topbar{
  position:absolute;top:0;left:0;right:0;z-index:40;background:none}
/* ⚠ 上面那条的重映射只写了 [data-screen=play] ✗ 真机又反馈「选关页没有避让灵动岛」✗
   顶栏在**每个**屏幕都有 ✗ 所以方向重映射必须对所有屏幕生效 ✗ 只有绝对定位是游戏页专属 ✓ */
body[data-force-landscape] .topbar{
  padding-top:calc(var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) + 8px);
  padding-left:calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 14px);
  padding-right:calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + 14px);
  padding-bottom:0}
/* 触摸键必须**脱离文档流**钉在旋转后的可见底边 ✗
   踩过的坑：媒体查询看不到旋转 ✗ 视口仍是竖屏 ✗ 布局按竖屏手机撑到本地 y≈605 ✗
   超过本地高度 100vw=390 ✗ 旋转映射把 y 换成 x ✗ 按键落到 x=390−605=−215 ✗
   **整个飞出屏幕** ✗ 症状：截图里画面正常 ✗ 但按键点不动（elementFromPoint 返回 null）✓ */
body[data-force-landscape][data-screen=play] .touch-controls,
body.touch-device[data-force-landscape][data-screen=play] .touch-controls{
  display:flex;position:absolute;left:0;right:0;bottom:0;z-index:30;
  padding-left:calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 10px);
  padding-right:calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + 10px)}
/* 角色出生在左下角 ✗ 正好被 ←/→ 压住 ✗ 把键调透一点 ✗ 隔着键也能看见角色 ✓
   （横屏紧凑模式里也是这个思路 ✗ 那边写的是 opacity:.87 ✓） */
body[data-force-landscape][data-screen=play] .touch-key,
body.touch-device[data-force-landscape][data-screen=play] .touch-key{opacity:.78}
/* 画面**铺满整个本地框**（844×390 ✗ 比例 2.16:1 ✗ 而游戏内容是 2:1）✓
   做法：让 canvas 用 object-fit:cover 填满 ✗ 多出来的 32px 上下各裁 16px ✓
     → 裁掉的正好是天花板/地板装饰边的各一半 ✗ 玩法行 1..12 一行不丢 ✓
     → 没有黑边、也没有变形（比横向拉伸 8% 强得多 ✗ 拉伸会把角色的圆脑袋压扁 ✓）
   ⚠ 必须连 body.touch-device 那一份一起写 ✗
   否则被第 87 行的 `body.touch-device[data-screen=play] .stage`（多一个类 ✗ 优先级高一级）
   压成 max-height:var(--app-height)=844 ✗ 我写的高度形同虚设 ✗ **这个坑第四次踩** ✓ */
body[data-force-landscape][data-screen=play] .stage,
body.touch-device[data-force-landscape][data-screen=play] .stage{
  position:absolute;top:0;left:0;width:100%;height:100vw;
  max-width:none;max-height:none;margin:0;aspect-ratio:auto}
body[data-force-landscape][data-screen=play] .stage canvas,
body.touch-device[data-force-landscape][data-screen=play] .stage canvas{
  display:block;width:100%;height:100%;object-fit:cover;object-position:center center}
/* 提示条改成**浮在画面上缘**✗ 不再占垂直空间（本地只有 390px ✗ 一行提示 40px 很贵 ✗）
   半透明白底压在天上 ✗ 平台游戏上缘基本是天空 ✗ 不挡地面 ✓ */
body[data-force-landscape][data-screen=play] .hint-line,
body.touch-device[data-force-landscape][data-screen=play] .hint-line{
  position:absolute;top:62px;left:10px;right:10px;z-index:20;
  pointer-events:none;font-size:12px;background:rgba(255,255,255,.86)}
/* 竖屏紧凑规则不生效 ✗ 那就在这儿把不必要的一行收掉 ✗ 给画面腾高度 ✓ */
body[data-force-landscape][data-screen=play] .gamebar,
body[data-force-landscape][data-screen=play] .footer{display:none}
/* 提示在强制横屏下是反的 ✗ 收起来 ✓ */
body[data-force-landscape][data-screen=play] .rotate-hint{display:none}

/* ── 对话框必须**单独**跟着转 ✗ 真机反馈「过关、换皮肤、继续旅途，所有功能都是竖屏的」──
   根因：index.html 里所有 <dialog> 都是 <main class="app"> 的**兄弟** ✗
        而旋转只挂在 body[data-force-landscape][data-screen=play] .app 上 ✗
        所以游戏画面转了 ✗ 弹窗还是竖的 ✓
   ⚠ 这里有个陷阱：把 <dialog> 挪进 .app 里是**没用**的 ✗
       因为 showModal() 会把它提升到 **top layer** ✗ 顶层元素不受祖先 transform 影响 ✓
       所以必须让 dialog 自己带上同样的变换 ✓
   变换参数和 .app 完全一致 ✗ 于是它在用户视角里也是 844×390 的横屏画布 ✓
   ⚠ 第一版写成「对话框铺满整个横屏画布」✗ 结果卡片被拉成 844 宽 ✗
      按钮变成 800px 长的横条 ✗ 内容全挤在上半截 ✗ 下半屏一片空白 ✓
      所以改成**在横屏画布里居中放一张卡片** ✗ 尺寸按用户视角给（和 .app 同一套坐标）✓
      max-width/max-height 用 100vh/100vw 反向夹住 ✗ 保证在任何比例的手机上都装得下 ✓
      （inset:0 这种新简写不能用 ✗ Chrome 61 基线不支持 ✗ 要写 top/right/bottom/left ✓）
   ⚠ 第二版用 top/right/bottom/left:0 + margin:auto 居中 ✗ 也是错的：
      卡片未旋转时是 680px 宽 ✗ 比物理视口 390px 还宽 ✗ 负余量下 auto 边距不生效 ✗
      盒子贴到 left:0 ✗ 绕自身中心一转就整体偏出屏幕 ✗ 实测左边距 −120px ✓
      正解：top/left:50% + translate(-50%,-50%) rotate(90deg) ✗
      元素中心正好落在视口中心 ✗ 任何尺寸溢出都不影响居中 ✓ */
body[data-force-landscape] dialog[open]{
  position:fixed;top:50%;left:50%;margin:0;
  width:680px;height:356px;
  max-width:calc(var(--app-height, 100vh) - 28px);
  max-height:calc(100vw - 28px);
  transform:translate(-50%, -50%) rotate(90deg);
  overflow:auto}
/* 横屏竖向只有 390px ✗ 而设置里的每一行基线是 margin:22px 0 ✗ 5 行光外边距就吃掉 220px ✗
   实测「设置」卡片还差 84px 要滚动 ✗ 也就是最后一个按钮看不见 ✗
   收到 8px 后只差 4px ✗ 再收到 6px 就完全放下了 ✓ */
body[data-force-landscape] dialog[open] .setting,
body[data-force-landscape] dialog[open] .toggle-setting{margin:6px 0}
body[data-force-landscape] dialog[open] .dialog-head{margin-bottom:12px}
/* 过关弹窗加了「发成笔记 / 存到相册」两个按钮之后内容又超高了 ✗
   实测分享按钮被挤到看不见的地方 ✗ 真机上也点不到 ✗
   横屏的卡片本来就是"宽而矮" ✗ 按钮竖排是在浪费唯一稀缺的那一边 ✗ 改成横排 ✓ */
body[data-force-landscape] dialog[open] .dialog-actions{flex-direction:row;flex-wrap:wrap}
body[data-force-landscape] dialog[open] .dialog-actions > button{flex:1 1 40%}
/* 弹窗里的按钮也要顺手挡住长按选字 ✗ 但**存档文本框**例外（要长按全选复制）✓ */
body[data-force-landscape] dialog[open] *:not(textarea):not(input){
  -webkit-user-select:none;user-select:none;-webkit-touch-callout:none}

/* ── 关掉游戏区的长按选字 ✗ 真机反馈「手机上长按会触发网页文字选择机制」──
   只关 .app ✗ 因为对话框在 .app **外面**（index.html 里 <dialog> 都是 <main> 的兄弟）✗
   存档对话框里那段 base64 还要靠长按全选复制 ✗ 所以绝不能写到 body 上 ✓
   touch-action:none 顺带挡掉双击缩放和滑动滚动 ✗ -webkit-touch-callout 挡 iOS 的放大镜 ✓ */
body[data-screen=play] .app,
body[data-screen=play] .app *{
  -webkit-user-select:none;user-select:none;
  -webkit-touch-callout:none;-webkit-tap-highlight-color:transparent}
body[data-screen=play] .stage,
body[data-screen=play] .stage canvas,
body[data-screen=play] .touch-controls,
body[data-screen=play] .touch-key{touch-action:none}
html,body{overscroll-behavior:none}

/* ── 选关页（旅途地图）的横屏重排 ✗
   转过来只是第一步 ✗ 选关页原本是给**竖屏**设计的一长列 ✗
   在 844×390 里不改排版的话 ✗ 会变成一条要滚很久的长列表 ✗ 又难看又难找 ✓
   所以：关卡网格改成 4 列 ✗ 关掉竖屏的锯齿错位 ✗ 缩小关卡圆牌 ✗ 收掉占地方的预览图 ✓
   （预览图在横屏里要吃掉 570px ✗ 比整屏还高 ✗ 只能收掉 ✗ 关卡名本身还在 ✓） */
/* ⚠ 选关页自己也要避让灵动岛 ✗ 真机反馈「选关这里没有考虑避让灵动岛」✓
   这里同样是**方向重映射**：本地左 = 物理顶（灵动岛 ✗ inset-top）
                            本地右 = 物理底（home 条 ✗ inset-bottom）
   上一版只写了 padding:8px 14px ✗ 于是章节标题直接压在灵动岛底下 ✓ */
body[data-force-landscape][data-screen=select] .map-screen{
  padding-left:calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 14px);
  padding-right:calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + 14px);
  padding-top:calc(var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) + 8px);
  padding-bottom:calc(var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) + 8px)}
body[data-force-landscape][data-screen=select] .map-heading h2{font-size:20px}
body[data-force-landscape][data-screen=select] .map-heading p{font-size:11px;margin:2px 0}
body[data-force-landscape][data-screen=select] .chapter-tabs{margin-top:6px}
/* ⚠ 千万别写 .stage{display:none} ✗ 我第一版就这么写的 ✗
   因为选关页的 .map-screen 是 position:absolute **挂在 section.stage 里面** ✗
   把 stage 一藏 ✗ 整张地图跟着消失 ✗ 真机上就是「只剩顶栏和页脚 ✗ 中间一片空白」✓
   正解：让 stage 从「固定 570px 高的绝对定位容器」改成**正常流** ✗
        再把 .map-screen 的 absolute 去掉 ✗ 高度就由内容自己撑着 ✗ 需要时靠 .app 滚动 ✓
   ⚠ 但 stage 里的预览大图基线是 570px 高 ✗ 比横屏整屏还高 ✗ 会把选关列表整个顶出屏幕 ✗
      所以让 stage 退成**全屏背景** ✗ 地图浮在它上面 ✗ 预览就当一张氛围底图 ✓ */
body[data-force-landscape][data-screen=select] .stage{
  display:block;position:absolute;top:0;left:0;width:100%;height:100%;
  min-height:0;max-height:none;aspect-ratio:auto;overflow:hidden;z-index:0}
body[data-force-landscape][data-screen=select] .stage canvas{
  display:block;width:100%;height:100%;object-fit:cover}
/* ⚠ 这里也**别**把 .map-screen 改成 position:static ✗ 我又踩了一次：
   它就是挂在 .stage 里面的 ✗ stage 一旦变成 110px 高 + overflow:hidden ✗
   地图在布局上有尺寸 ✗ 但**视觉上被整块裁掉** ✗ 真机上表现为「中间一片空白」✓
   正解是顺着原设计：stage 当全屏背景 ✗ map-screen 当全屏可滚动浮层 ✓ */
body[data-force-landscape][data-screen=select] .map-screen{
  position:absolute;top:0;left:0;width:100%;height:100%;
  overflow-y:auto;-webkit-overflow-scrolling:touch;z-index:1}
body[data-force-landscape][data-screen=select] .level-path{
  grid-template-columns:repeat(4, 1fr);gap:0;margin:8px 0}
body[data-force-landscape][data-screen=select] .level-node{
  transform:none;padding:2px 6px;flex-direction:row;justify-content:flex-start}
body[data-force-landscape][data-screen=select] .level-node:before{display:none}
body[data-force-landscape][data-screen=select] .node-number{width:32px;height:32px;font-size:13px}
body[data-force-landscape][data-screen=select] .node-name{font-size:12px}
body[data-force-landscape][data-screen=select] .map-bottom{flex-direction:row;gap:12px;align-items:center}
body[data-force-landscape][data-screen=select] .map-actions{width:auto}

/* ── 触摸键加文字标签 ✗ 起因是玩家问「收绳子的 F 键，手机怎么操作」──
   键一直都在（↔ 图标 ✗ aria-label="收绳"）✗ 但 aria-label 只给读屏 ✗
   视觉上就是一个 ↔ ✗ 没人认得出那是收绳 ✓
   而且收绳是**按住**才生效 ✗ 还得和方向键同时按 ✗ 是双指操作 ✓
   实测（真多点触控）：牵手 → 按住收绳（braced=true ✗ 布布被钉住）→ 再加一指按方向 ✗
   braced 依然 true ✗ 两根手指可同时生效 ✓ 机制没问题 ✗ 缺的只是"看得懂" ✓
   用 ::after + attr(data-label) ✗ 不加元素 ✗ 绝对定位不参与布局 ✗ 圆形键不会被撑变形 ✓ */
.touch-key{position:relative}
.touch-key::after{
  content:attr(data-label);position:absolute;left:0;right:0;bottom:3px;
  font-size:9px;line-height:1;text-align:center;opacity:.8;pointer-events:none}
.touch-key svg{transform:translateY(-4px)}

/* ── 第三版排版：画面居中 + 四周固定画框色（玩家要求）──
   前两版的取舍都偏了，记在这里免得再翻：
     第一版「铺满 + 让出安全区」 → 两侧留白 ✗ 「像没铺满」✓
     第二版「object-fit:cover 铺满」 → 不留白了 ✗ 但状态栏和灵动岛直接压在画面上 ✓
   这一版换思路：**不让画面去够边缘** ✗ 而是把画面缩到安全区以内**居中** ✗
   边缘用**固定色**（游戏调色板里的 ink #3A322A）填成"画框" ✗ 于是
     · 状态栏 / 灵动岛 / home 条 压到的是画框 ✗ 不是画面 ✓
     · 画面完整可见 ✗ 不裁切 ✗ 不变形（2:1 内容正好放进 2:1 的盒子）✓
     · 深色画框把画面衬出来 ✗ 比"白留白"像是有意为之 ✓
   注意：宽度用 flex 居中 + padding 让位 ✗ 高度让 canvas 自己按固有比例撑 ✗
        这样即使容器是 Chrome 61 不支持 aspect-ratio ✗ 也不会塌 ✓ */
body[data-force-landscape][data-screen=play] .app{
  display:flex;align-items:center;justify-content:center;background:#3A322A;
  padding-left:var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  padding-right:var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))}
body[data-force-landscape][data-screen=play] .stage,
body.touch-device[data-force-landscape][data-screen=play] .stage{
  position:static;top:auto;left:auto;bottom:auto;
  width:100%;height:auto;max-width:100%;max-height:100%;aspect-ratio:2/1;margin:0}
body[data-force-landscape][data-screen=play] .stage canvas,
body.touch-device[data-force-landscape][data-screen=play] .stage canvas{
  display:block;width:100%;height:auto;object-fit:contain;object-position:center center}
/* 方向键放大 ✗ 玩家要求 ✓ 74px 差不多 40dp 以上 ✗ 拇指才按得住 ✓ */
body[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key{
  width:var(--touch-arrow);height:var(--touch-arrow)}
body[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key svg{
  width:30px;height:30px}

/* ══ 第五版：自适应视口 + 横向延展 ══
   玩家反馈「两侧有约 90px 的纯色画框，很难看」✗ 而 object-fit:cover 铺满又要裁掉
   上下各 16px（会削到关卡的天花板/地板）✗ 两条路都不能走 ✓
   所以换成业界 2D 手游的「自适应视口」做法：

     **画布尺寸跟着屏幕可用比例走**（宽度可变）✗ 世界 896×448 在画布中**水平居中** ✗
     两侧多出来的区域由 art.js 用关卡自己的背景 + 地形**横向延展**填满 ✓
       → 没有黑边：画布把整个本地视口铺满
       → 没有裁切：世界一格不动，只是外面多画了一圈
       → 没有变形：世界本身的比例、坐标、碰撞全部不变
       → 灵动岛 / 状态栏压到的是延展出来的天空或土层 ✗ 压不到角色和出口

   画布宽度由 game.js 的 syncViewport() 按 .stage 的实际宽高比算：
       viewW = VH * stageW / stageH（≥ 896，且保证世界的居中偏移是整数）
       stage 正好 2:1（桌面 / 真横屏基线）→ viewW === 896 → 与旧版逐像素一致 ✓
   ⚠ 旋转后的本地坐标：本地左 = 物理顶（灵动岛）✗ 本地右 = 物理底（home 条）
     本地上 = 物理右 ✗ 本地下 = 物理左 ✗ 四个变量只在这里定义一次 ✗ 别处只用 var(--sa-*) ✓ */
html[data-force-landscape]{
  --sa-left:var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  --sa-right:var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
  --sa-top:var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
  --sa-bottom:var(--safe-area-inset-left, env(safe-area-inset-left, 0px))}
/* ⚠ --dir-shift / --act-shift 不在这里定 ✗ 它们由「容器返回键禁区」推导（见文件末尾第六版）✓ */
/* ① 整块本地画布不再留内边距：画面铺满 844×390，控件自己避让安全区。
      （旧版这里用 --thumb 预留了 84px 拇指带 + 左右安全区内边距 ✗
        那正是「两侧约 90px 画框」和「下方一条画框」的来源 ✓） */
body[data-force-landscape] .app{padding:0;background:#cfe4dd}
body[data-force-landscape][data-screen=play] .app{display:block;background:#cfe4dd}
/* ② 舞台 = 整个本地视口；画布把舞台铺满。
      画布的固有宽高比是照舞台算出来的（viewW = VH × 舞台宽高比）✗ 所以
      object-fit:contain 只会留下 ≤0.5px 的亚像素缝（viewW 取整的残差）✗ 肉眼不可见 ✓
      用 contain 而不是 fill 是为了**绝不变形**：
        容器比 2:1 窄的极端情况（例如 iPad 竖屏转过来是 1.33:1）✗
        viewW 会被夹回 896 ✗ fill 会把世界纵向拉伸 1.5 倍 ✗ contain 则上下留边（和旧版一致）✓
      目标机型（2.16:1）下两者等价，都不留边 ✓ */
body[data-force-landscape][data-screen=play] .stage,
body.touch-device[data-force-landscape][data-screen=play] .stage,
body[data-force-landscape][data-screen=select] .stage,
body.touch-device[data-force-landscape][data-screen=select] .stage{
  position:absolute;top:0;left:0;width:100%;height:100%;
  max-width:none;max-height:none;min-height:0;margin:0;
  aspect-ratio:auto;flex:none;border-radius:0;overflow:hidden;background:#cfe4dd}
body[data-force-landscape][data-screen=play] .stage canvas,
body.touch-device[data-force-landscape][data-screen=play] .stage canvas,
body[data-force-landscape][data-screen=select] .stage canvas,
body.touch-device[data-force-landscape][data-screen=select] .stage canvas{
  display:block;width:100%;height:100%;object-fit:contain;object-position:center center}
/* ③ 顶栏 / 触摸键 / 地图页：安全区映射保留（一律只用 var(--sa-*)） */
body[data-force-landscape] .topbar{
  padding-top:calc(var(--sa-top) + 8px);
  padding-left:calc(var(--sa-left) + 14px);
  padding-right:calc(var(--sa-right) + 14px);
  padding-bottom:0}
body[data-force-landscape][data-screen=play] .touch-controls,
body.touch-device[data-force-landscape][data-screen=play] .touch-controls{
  padding-left:calc(var(--sa-left) + 10px);padding-right:calc(var(--sa-right) + 10px);
  padding-top:var(--sa-top);padding-bottom:var(--sa-bottom)}
body[data-force-landscape][data-screen=select] .map-screen{
  padding-left:calc(var(--sa-left) + 14px);padding-right:calc(var(--sa-right) + 14px);
  padding-top:calc(var(--sa-top) + 8px);padding-bottom:calc(var(--sa-bottom) + 8px)}
/* 方向键整组右移 ✗ 玩家要求 ✓ 原来贴着左边（只离安全区 10px）✗ 左手拇指没地方放 ✓
   用一个变量控制 ✗ 想再挪就改这一个数 ✓ */
body[data-force-landscape][data-screen=play] .touch-group:first-child,
body.touch-device[data-force-landscape][data-screen=play] .touch-group:first-child{
  margin-left:var(--dir-shift)}
/* 方向键放大 ✗ 玩家要求 ✓ 74px 差不多 40dp 以上 ✗ 拇指才按得住 ✓ */
body[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key{
  width:var(--touch-arrow);height:var(--touch-arrow)}
body[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key svg{
  width:30px;height:30px}

/* ── 触摸按键带高度：给 game.js 读的**设计常量**（唯一来源）──
   为什么要有这个变量：容器（小红书 WebView）里首屏 <body data-screen=select> ✗
   .touch-controls 是 display:none ✗ 那会儿 getBoundingClientRect() 全是 0 ✗
   game.js 若只靠实测反推按键带高度 ✗ 就会把缩放算成"没有按键带"（画面偏大）✗
   select → play 之后又可能一个 resize / orientationchange / visualViewport / ResizeObserver
   都不触发 ✗ 于是画面一直偏大、方向键压回角色身上 ✓
   所以：高度在 CSS 里定义（和按键尺寸同源）✗ 实测只当校对与兜底 ✓
   --touch-band = 按键带在**画布底部**盖住多少本地 px
     （这两个分支里画布都铺满整个视口高 ✗ 所以它就是容器自身的高度）
     · 横屏紧凑（真横屏手机）：容器上内边距 4px + 最高那个键（跳跃键）
     · 强制横屏（竖屏转 90°）：容器上下内边距 0 ✗ 最高的键是放大后的方向键 --touch-arrow
     · 非触摸 / 选关页：game.js 用「控件有没有布局盒子」判断 ✗ 没有带子就是 0 ✓ */
:root{
  --touch-key-w:54px; --touch-key-h:54px;
  --touch-jump-w:65px; --touch-jump-h:65px;
  --touch-arrow:74px;
  --touch-band:0px}
@media(max-width:700px){:root{
  --touch-key-w:43px; --touch-key-h:48px;
  --touch-jump-w:58px; --touch-jump-h:58px}}
@media(max-width:360px){:root{
  --touch-key-w:40px; --touch-key-h:44px;
  --touch-jump-w:46px; --touch-jump-h:48px}}
@media(max-height:530px) and (orientation:landscape){:root{
  /* 这个分支里画布**不一定**铺满视口高：宽 ≤700px 时它按 2:1 反推高度（高 = 100vw/2）✗
     所以按键带**压住画布**的高度 = 画布下沿 − 按键带上沿 ✗ 不能直接拿容器高 ✓
     容器高 = 4px 上内边距 + 最高的键（跳跃键）；画布下沿 = min(100vw/2, 100vh) ✓
     例：844×390（宽屏）→ min(422,390) − (390−69) = 69 ✓ 667×375 → 333.5 − (375−62) = 20.5 ✓ */
  --touch-band-h:calc(4px + var(--touch-jump-h));
  --touch-band:min(var(--touch-band-h), max(0px, calc(100vw / 2 - 100vh + var(--touch-band-h))))}}
body[data-force-landscape][data-screen=play]{
  --touch-band:var(--touch-arrow)}

/* ── 左手跳跃键（修复「收绳 + 跳」同侧按不了）──
   原来右侧那一组里「收绳(KeyF)」和「跳(Space)」是相邻两个圆键 ✗ 一根拇指按不了两个 ✓
   而收绳救援关要求这两个键**同时按住**（收绳一松，braced/reel 就掉 ✗ 二段跳还会触发，
   但绳子松了拉不上去 ✗ 人落回坑里）✗ 所以玩家在手机上被这个布局逼着松开收绳 ✓
   现在左侧方向键组里再放一个跳键（右侧那个**保留**）：
     · 右手拇指按住收绳 ✗ 左手拇指点左跳 ✓
     · 或者两指都在左侧/右侧的任意组合 ✓ 两条路都通 ✓
   ⚠ 两条硬约束（都测过）：
     ① 它不能改变按键带高度 --touch-band ✗ 强制横屏下 --touch-band = --touch-arrow = 74px ✗
        所以左跳键**不能高过方向键** ✗ 下面把它按 --touch-jump-* 定尺寸（≤74）✓
        行高仍由 74px 的方向键决定 ✗ 常量与实际容器高一致 ✓
     ② 它不能把左侧组撑到右边组身上去 ✗ 窄屏（物理宽 ≤360 ✗ 本地宽 568）间隙最小 ✗
        现在这个间隙由「容器返回键禁区」的让位量（54px）+ 右组反侧让位一起决定 ✗
        改动后必须重跑 node tools/diag-touch-layout.js（无重叠 + 全部可命中）
        以及 node tools/check-viewport.js 的「容器返回键禁区」段（两组间隙、禁区都够）✓
   ⚠ 尺寸/间隙改动后必须重跑 node tools/diag-touch-layout.js ✗ 看"无重叠 + 全部可命中" ✓ */
body[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key.jump,
body.touch-device[data-force-landscape][data-screen=play] .touch-group:first-child .touch-key.jump{
  width:var(--touch-jump-w);height:var(--touch-jump-h)}
/* 反侧让位：右组（换人/牵手/收绳/跳）退到「本地右下角禁区」以内。
   禁区模型与让位量见文件末尾「第六版：容器返回键禁区」✗ 这里只接变量 ✓ */
body[data-force-landscape][data-screen=play] .touch-group.touch-act,
body.touch-device[data-force-landscape][data-screen=play] .touch-group.touch-act{
  margin-right:var(--act-shift, 0px)}

/* 方向键改小（玩家反馈：「方向键靠小一点，往左移动一点，现在有点靠右」）
   这正是「带高与按键尺寸同源」该发挥作用的地方 ✗ 只动这两个变量即可 ✓
   注意副作用是**好的**：按键变小 → 按键带变矮 → 画面的缩放 s 变大 ✗ 世界反而更清楚一点 ✓
   但带高是遮挡保证的输入 ✗ 所以改完必须重跑遮挡与可达验收 ✗ 不能只看键本身 ✓
   ⚠ --dir-shift 已在 2026-09 交给「容器返回键禁区」接管（见文件末尾第六版）✗ 别在这里再写死 ✓
     历史值：56px（初版，恰好躲开返回键）→ 10px（「太靠右」）→ 32px（「又太靠左」）
     32px 正是这次仍被返回键压住的根因：禁区右沿比它多 22px ✓ */
html[data-force-landscape]{--touch-arrow:62px}
/* 左组里还混着「跳」✗ 这条只缩方向键 ✗ 别把跳键一起缩 ✓ */
body[data-force-landscape][data-screen=play] .touch-group.touch-move .touch-key:not(.jump),
body.touch-device[data-force-landscape][data-screen=play] .touch-group.touch-move .touch-key:not(.jump){
  width:62px;height:62px}
body[data-force-landscape][data-screen=play] .touch-group.touch-move .touch-key:not(.jump) svg{
  width:26px;height:26px}

/* ══ 第六版：容器外壳「返回键」禁区 —— 物理角 → 本地坐标，按实时比例换算 ══
   ── 先把轴映射写死（这个项目栽过五次的坑，别凭记忆推坐标轴）──
       用户横向（本地 x） = 物理 y              → localX = rect.top,                 localW = rect.height
       用户纵向（本地 y） = innerWidth − 物理 x  → localY = innerWidth − rect.right, localH = rect.width
     与 tools/check-viewport.js / check-viewport-reach.js / diag-touch-layout.js 的 uTop/uLeft 逐字一致；
     本轮实测（多比例）：390×844 转屏后本地画布 = 844×390，← 键盒子 = [101,328]–[163,390]（本地 px）
     ✗ 下沿 390 正好等于本地高 ⇒ 它**贴死本地左下角** ✗ 正好是物理左上角投影过来的那一格 ✓
     ⇒ 物理左上角 ≡ 本地左下角 ｜ 物理左侧条 ≡ 本地下边缘 ｜ 物理长边 ≡ 本地宽

   ── 为什么必须按实时宽高比换算（而不是再试一个 --dir-shift）──
     · 容器外壳（那个返回键）钉在**物理角**上 ✗ 不跟我们的 CSS 旋转走；
       玩家把手机横过来看 ✗ 它就落在**本地（用户视角）左下角** ✓
     · 本地框尺寸 = 物理长边 × 物理短边 = var(--app-height,100vh) × 100vw：
       比例一变 ✗ 本地宽（= 物理长边）就变 ✗ 禁区在本地坐标里的位置跟着变 ✓
       （反侧禁区的本地 x = 本地宽 − 禁区边长 ⇒ 它是 --local-w 的函数 ✗ 写死无效 ✓）
     · 同一台机器上 ✗ 容器报的安全区（外壳自己的内缩）也会变 ✗ 所以还要串上 --sa-* ✓

   ── 禁区模型（保守取值，依据与余量都写在这）──
     · 真机测不到：小工具 JSBridge 只有 postNote / saveImageToPhotosAlbum / openRedPage /
       writeTempFile ✗ 没有「导航栏 / 返回键矩形」这类查询接口
       （见 .skill/minitool-zip-builder/references/jsbridge-api.md）⇒ 只能保守假设 ✓
     · 键身取业界常见的 44×44pt（iOS）/ 48×48dp（Android）的**较大者 48px** ✓
     · 容器外壳自己还有内边距 / 描边 / 圆角 ✗ 而我们的键是**半透明**圆键（贴边也会"看着被压住"）
       ⇒ 再留 20px 命中与视觉余量 ✓
     · 于是「禁区」= 以物理角为角的 **68×68** 方块（= 48 + 20）✓
     · 返回键钉在物理左侧的哪个角 ✗ 容器不给接口 ✗ 不赌：两个角都让开 ✓
         ① 物理**左上**角 → 本地**左下**角（玩家反馈「左方向键遮挡返回键」= 这一格 ✗ 左组）
         ② 物理**左下**角（任务描述的字面位置）→ 本地**右下**角（右组：换人/牵手/收绳/跳）
     · 纵向不用另算：物理左边缘 ≡ 本地下边缘 ✗ 而 env(safe-area-inset-left) 两侧同源
       （--sa-bottom ✗ 已经是 .touch-controls 的 padding-bottom）✓

   ── 让位量是**推导**出来的，不是试出来的 ──
     左组左沿 = (--sa-left + 10px 带内边距) + --dir-shift ｜ 禁区右沿 = --sa-left + --back-zone
       ⇒ --dir-shift ≥ --back-zone − 10px = 58px        （--sa-left 两边都有 → **约掉** ✓）
     右组右沿 = (本地宽 − --sa-right − 10px) − --act-shift ｜ 禁区左沿 = 本地宽 − --sa-right − --back-zone
       ⇒ --act-shift ≥ --back-zone − 10px = 58px        （--sa-right 也约掉 ✓ 两个角对称 ✓）
     ⚠ 注意：这里**让位量的数值**与机型/安全区无关（都是 58px ✗ 因为禁区边界和触屏带内边距
       跟着同一组安全区一起平移）——真正随比例变的是**禁区在本地坐标里的位置**：
         · 反侧禁区 x = 本地宽 − --back-zone = var(--app-height,100vh) − 68  ← 物理长边，比例一变就变
         · 上下方向：禁区 y = 本地下边缘的 68px = 100vw 的最后一截
       所以「按实时宽高比换算」的落点是**禁区矩形**✗ 让位量是它推出来的结果 ✓
       检查（tools/check-viewport.js 第 ④ 段）就是把 --local-w / --local-h / --sa-* **实测**出来现算禁区 ✗
       不是抄这个 58 ✓ 证据：把 let 位量改回写死的旧值（BACK_OFF=1）✗ 8 种比例全红 ✓
     历史值印证：旧 32px ⇒ 左组左沿 = sa+42 ✗ 比禁区右沿 sa+68 少 26px ⇒ **每台机器都压住** ✓
       （和玩家「不同比例，左方向键还是遮挡了固定的返回键」一致 ✓）
     初版 56px ⇒ 左组左沿 = sa+66 ✗ 仍比禁区右沿少 2px（说明它侥幸够用 ⇒ 真实禁区右沿 ≤ sa+66）✓
       本公式给 sa+68 ✗ 比那个已知能用的值再保守 2px ✓
     ⚠ 保守余量都集中在 --back-key / --back-key-gap 两个数里 ✗ 真机量到返回键实际矩形后只改这里 ✓

   ── Chrome 61 兼容（css-compatibility.md §2：min/max/clamp 晚于 61）──
     基线层直接给推导值（对任何安全区都成立）✗ 增强层再用 max() 守住拇指下限 ✓ */
html[data-force-landscape]{
  --local-w:var(--app-height, 100vh);   /* 本地宽 = 物理长边（比例一变就变） */
  --local-h:100vw;                      /* 本地高 = 物理短边 */
  --back-key:48px;                      /* 44pt / 48dp 取大者 */
  --back-key-gap:20px;                  /* 容器外壳内边距 + 半透明键的视觉与命中余量 */
  --back-zone:calc(var(--back-key) + var(--back-key-gap));   /* 68px 禁区边长 */
  --dir-shift-min:32px;                 /* 玩家调过的拇指舒适下限（让位不许低于它） */
  --dir-shift:calc(var(--back-zone) - 10px);                 /* 58px 基线（Chrome 61 可用） */
  --act-shift:calc(var(--back-zone) - 10px)}                 /* 58px 反侧基线 */
@supports (width: max(1px, 2px)){
  html[data-force-landscape]{
    /* 禁区边界 − 触屏带内边距（安全区约掉 → 与机型无关，恒 58px）
       ⚠ 保留 max(--dir-shift-min)：将来若有人把 --back-zone 调小 ✗ 拇指下限仍在 ✓ */
    --dir-shift:max(var(--dir-shift-min), calc(var(--back-zone) - 10px));
    --act-shift:max(0px, calc(var(--back-zone) - 10px))}}
