chore: 归档山河拼图 2.2 现有源码 #1
@@ -25,3 +25,12 @@ __pycache__/
|
||||
# Development research: only the map snapshot used by build-time audit is required
|
||||
/research/*
|
||||
!/research/china.json
|
||||
|
||||
# Compact source index required by the detailed-map build audit
|
||||
!/research/detail-map-index.json
|
||||
|
||||
# Compact province-child index required to prevent missing-region regressions
|
||||
!/research/prefecture-map-index.json
|
||||
|
||||
# Compact county source index required by offline-stage integrity checks
|
||||
!/research/county-map-index.json
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 山河拼图
|
||||
|
||||
参考用户提供的日本地图下落合并视频开发的小红书离线小工具。正式交付包含**省级版、地级版**;县级素材已备好,未进入游戏发布包。
|
||||
参考用户提供的日本地图下落合并视频开发的小红书离线小工具。正式交付包含**省级版、地级版**,以及四直辖市县区、港澳分区、台湾县市细分关卡。新增329个地级行政区的县区关卡,共364关,支持全国→省域→城市县区的三级选关。
|
||||
|
||||
回正辅助现为“回正一次”按钮:只调整点击时已落下的地块,没有持续模式,新块和后续碰撞仍自由转动。
|
||||
|
||||
项目独立存放于 `ProjectRednote/shanhe-puzzle/`。以下命令请在 `shanhe-puzzle` 目录执行。仓库仅跟踪源码、测试和构建脚本、依赖清单、本文档,以及运行和构建校验必需的地图数据。
|
||||
|
||||
@@ -8,78 +10,99 @@
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build
|
||||
npm run dev
|
||||
# http://localhost:4173
|
||||
npm test
|
||||
npm run build
|
||||
npm run test:browser
|
||||
npm run test:results
|
||||
npm run test:interactions
|
||||
npm run test:container
|
||||
npm run test:swipes
|
||||
npm run test:staging
|
||||
```
|
||||
|
||||
浏览器测试目前默认使用 macOS 已安装的 Google Chrome,路径见 `tests/browser.mjs`。构建要求 Python 3,以及官方 [minitool-zip-builder 1.8.0 Skill](https://fe-static.xhscdn.com/minitool/20261015161653/minitool-zip-builder-1.8.0.skill)。克隆后需将 Skill 解压到本地 `.codex/skills/minitool-zip-builder/`,该目录应直接包含 `SKILL.md`、`references/` 和 `scripts/`。本地工具、依赖、构建输出和报告均已忽略,不推送到代码库。所有运行依赖与地图随最终 ZIP 提供,无联网运行逻辑。
|
||||
browser/staging为当前手动落子与难度确认流程;其余旧浏览器脚本中依赖定时下落或直接开局的场景需要更新,不列入上述建议命令。本次通过CUA实际操作验证界面,未运行CLI浏览器脚本。浏览器测试目前默认使用 macOS 已安装的 Google Chrome,路径见 `tests/browser.mjs`。构建要求 Python 3,以及官方 [minitool-zip-builder 1.8.0 Skill](https://fe-static.xhscdn.com/minitool/20261015161653/minitool-zip-builder-1.8.0.skill)。克隆后需将 Skill 解压到本地 `.codex/skills/minitool-zip-builder/`,该目录应直接包含 `SKILL.md`、`references/` 和 `scripts/`。本地工具、依赖、构建输出和报告均已忽略,不推送到代码库。所有运行依赖与地图随最终 ZIP 提供,无联网运行逻辑。
|
||||
|
||||
上传产物:`artifacts/shanhe-puzzle-xhs.zip`。根目录直接包含 `index.html`,不是项目源码压缩包。源代码在 `src/`,静态构建在 `dist/`。
|
||||
上传产物:`artifacts/shanhe-puzzle-xhs.zip`。根目录直接包含 `index.html`,不是项目源码压缩包。源代码在 `src/`,静态构建在 `dist/`。发行包共186个文件:173个同省双关县区脚本和13个基础文件,保留全部364关。预检和打包器均强制限制文件数不超过200,不包含node_modules、.git、原始素材或测试报告。
|
||||
|
||||
**必须在创服平台模拟器、Android 真机扫码、iOS 真机扫码验证同一 ZIP。** 本地浏览器仅用于开发回归和视觉检查,不能替代小红书容器验收。Chrome 61 / Android 8.1 兼容性、CSS 兼容性及真机性能均未实测;原生发笔记、相册授权和保存需要真机验证。
|
||||
|
||||
本机预览服务监听 `127.0.0.1:4173`,可以在这台Mac的系统浏览器打开 `http://localhost:4173/`;与Codex内置浏览器使用同一个服务。当前已有服务时无需重复启动。
|
||||
本机预览默认提供dist中的构建产物;修改源码后需先重新运行npm run build。服务监听 `127.0.0.1:4173`,可以在这台Mac的系统浏览器打开 `http://localhost:4173/`;与Codex内置浏览器使用同一个服务。当前已有服务时无需重复启动。
|
||||
|
||||
## 版本范围
|
||||
|
||||
- 省级版:全国 34 个省级行政区,各出现一次,合并为一个整体即通关。
|
||||
- 地级版:选择省或自治区,提供 27 个关卡、333 个地级行政区,包含地级市、自治州、地区、盟。
|
||||
- 排除县、区、县级市,以及济源、仙桃等省直辖县级单位。数据源会把部分县级单位标为 `city`,构建时进一步用行政区代码排除。
|
||||
- 北京、天津、上海、重庆、香港、澳门不设地级关卡;数据源暂缺台湾省地级边界,未生成对应地级关卡。
|
||||
- 本机县级原始边界位于 `data/raw/`,共 335 组、2840 个县级单元;预处理素材位于 `data/county-materials/`,另附独立素材 ZIP。这些开发素材全部保留本地,不进入 Git 或发布包。
|
||||
- 地级版:选择省或自治区,提供27个省内关卡、333个地级行政区及30个省直辖县级地区,合计363块,包含地级市、自治州、地区、盟和省直辖地区。
|
||||
- 省内关卡保留省级源素材的全部直接子区域,补回湖北仙桃、潜江、天门、神农架(13→17块),海南15个省直辖地区(4→19块),河南济源(17→18块),新疆素材中的10个直属市(14→24块)。这四关标为“省域拼图”,说明包括省直辖县级地区;不向下展开普通地级市所属县区。范围以2026-10-16下载的源素材快照为准,不声称当前最新区划完备性。
|
||||
- 北京、天津、上海各16区,重庆38个区县:显示“县区拼图”。香港18个分区、澳门8个源素材分区:显示“分区拼图”,不冒称县级行政区。
|
||||
- 台湾省20个县市:显示“县市拼图”,不与市内各区混级。来源为国土测绘中心COUNTY_MOI_1090820开放快照,2026-10-17下载,遵循政府资料开放授权条款第1版。金门、连江属福建,不重复列入台湾关卡;所有源外环保留,远海岛屿移位示意在准备页明确标注。
|
||||
- 全国入口不显示返回按钮,只有进入细分地图后才显示“全国地图”;导航行保留固定高度,出现或隐藏按钮不改变地图主体、开始按钮位置。
|
||||
- 本机县级原始边界位于 `data/raw/`,共 335 组、2840 个县级单元;预处理素材位于 `data/county-materials/`,另附独立素材 ZIP。原始数据与备用素材均保留本地,不进入Git;正式包和Git仅包含已开放关卡所需的简化轮廓。
|
||||
|
||||
## 玩法与得分
|
||||
|
||||
进入页选择省级 / 地级版、地区、四档下落速度和三档弹性,点击“开启这局旅程”后开局。在容器内快速轻划并松手,给容器一次惯性晃动;划过位置附近的地块反应更强。每次轻划只在松手时触发一次,地块获得带少量方向、力度差异的冲量后自由运动,手指只能决定大致方向。慢拖、按住、取消触摸或暂停不会持续施力,也不会抓取、牵引或瞬移单块碎片。底部只有一个无文字的圆形暂停图标,统一查看本局旅程、继续、结束或重开;电脑保留方向键辅助。弹跳、倾斜和速度有上限。
|
||||
进入页以中国地图为主体,点击“开启旅程”并确认难度后开始全国34块省级拼图;点击地图上的省区即可钻入对应地级、县区、县市或分区拼图准备页;省域地图上的地级市可再进入该市县区拼图,依次返回省域和全国。地图上的名称也能点击,支持键盘聚焦后按 Enter / 空格。原先未开放的7个地区现已按县区、县市或分区层级开放;34个省级地区均可进入细分关卡。
|
||||
|
||||
只有真实相邻关系、正确地理位置接近时才自动吸合。全部落下后仍可晃动,直到通关、稳定堆过红线超过 2.4 秒,或主动结束。底部圆形暂停图标打开旅程菜单,可继续、结束旅程、重新开局。结束旅程会保留成绩,即使零分或未通关也会生成图片;通关和判负自动生成。
|
||||
“这次要拼 N 块山河”、目的地和本地最佳与地图放在同一个场景里。地图场景、开局栏按可见视口确定固定尺寸,选省、返回、打开或关闭设置均不重新分配高度。首页难度仅作展示,吸附难度和碰撞弹性仅在点击“开启旅程”后的确认弹窗内修改,确认出发后生效,取消不修改。每次开启、重新开局或再来一局,都必须确认难度;确认前不启动或重置游戏,取消重开返回原局暂停页。所有对局固定为随心落子,不再提供下落速度选择。吸附默认“轻松拼”,弹性保留四档。
|
||||
|
||||
省级海岛使用明确标注的“跨海连接”;地级版中因海域、未纳入本关的省直辖县等产生的间隔,使用明确标注的“隔区连接”。这两种连接都是游戏规则,不能当作陆地邻接关系。入口地图保留相对地理位置。
|
||||
进入游戏后,首块及后续每一块先在红线上方准备,尚未进入物理世界,不计入落下数、合并或堆顶判断。按住上方待落区左右拖动可调整下一块横向落点,按真实轮廓限制左右边界;预览按预备区空间显示,小地区有最小可见尺寸。释放后按当前游戏比例进入物理世界。
|
||||
|
||||
| 档位 | 出片间隔 | 重力参数 | 得分倍率 |
|
||||
所有关卡均为“随心落子”:等待不会自动释放,在红线上方左右调整并松手时才落一块,得分倍率×1。取消触摸、暂停、切后台、横竖屏变化不视为松手落块;暂停冻结游戏计时。旧手势不能继续拖动已落图块或误放下一块。电脑聚焦画布后可按Enter辅助释放待落块。
|
||||
|
||||
在红线下方快速轻划并松手,给容器一次惯性晃动;划过位置附近的地块反应更强。每次轻划只在松手时触发一次,地块获得带少量方向、力度差异的冲量后自由运动,手指只能决定大致方向。慢拖、按住、取消触摸或暂停不会持续施力,也不会抓取、牵引或瞬移单块碎片。底部只有一个无文字的圆形暂停图标,统一查看本局旅程、继续、结束或重开;电脑保留方向键辅助。弹跳高度、平移速度和旋转速度有上限,旋转角度不受限制。
|
||||
|
||||
全国版中,同一组合包含一个玩法地区的全部省级地块时,触发一次“地区拼合完成”庆祝:金色轮廓、短暂提示卡和少量彩屑。七组为华北、东北、华东、华中、华南、西南、西北,互不重复覆盖34块;华东含台湾,华南含香港、澳门。仅为玩法分组,完整名单见src/game.js。一个地区可以属于更大的组合,只落齐而未合并不触发。每组每局仅一次,连续达成依次展示。细分关卡按最大组合达到25%、50%、75%提示“初见山河 / 半程相逢 / 山河渐全”;少于两块或已经相当于全关的门槛略过,一次跨多个门槛只展示最高新阶段。阶段提示不额外加分。
|
||||
|
||||
全部拼完后播放2.6秒“山河,完整了”庆祝,再进入成绩页;物理、得分和游玩计时冻结,切后台暂停演出、返回继续。游戏内演出及阶段反馈使用同一Canvas绘制器进入完整游玩视频,视频保留通关演出时长,成绩图的游玩时长不增加。尊重减少动态效果设置,保留静态达成提示。
|
||||
|
||||
只有真实相邻关系、正确地理位置接近时才自动吸合。全部落下后仍可晃动,直到通关、同一组地块稳定堆过红线超过 2.4 秒(出生保护 5 秒,须经实际接触连到底部;短暂弹起、贴侧墙或悬空不计),或主动结束。底部圆形暂停图标打开旅程菜单,可继续、结束旅程、重新开局。结束旅程会保留成绩,即使零分或未通关也会生成图片;通关和判负自动生成。
|
||||
|
||||
省级海岛使用明确标注的“跨海连接”;地级版中因海域、源素材中不相接地区等产生的间隔,使用明确标注的“隔区连接”。这两种连接都是游戏规则,不能当作陆地邻接关系。入口地图保留相对地理位置。
|
||||
|
||||
| 吸附难度 | 合并位置容差 | 开始靠拢范围 | 相对角度容差 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 佛系看山 | 8.5 秒 | 0.32 | ×1 |
|
||||
| 说走就走 | 6 秒 | 0.48 | ×1.5 |
|
||||
| 一路狂飙 | 4 秒 | 0.7 | ×2 |
|
||||
| 山河起飞 | 2.5 秒 | 1 | ×3 |
|
||||
| 轻松拼(默认) | 84px | 148px | 约57° |
|
||||
| 刚刚好 | 52px | 98px | 约38° |
|
||||
| 严丝合缝(原规则) | 26px | 45px | 约16° |
|
||||
|
||||
每次合并分数 = `(100 + 40 × (合并后块数 − 2)) × 连合系数 × 速度倍率 × 特殊连接系数`,四舍五入到整数。8.5 秒内再次合并视为连合,系数每次增加 0.25,从 1 起、最高 2;跨海和隔区连接额外乘 0.8。通关再奖 `1000 × 速度倍率`。轻划不限次数、不扣分。弹性不改变得分倍率。
|
||||
距离为游戏Canvas逻辑像素。轻松和普通档围绕真实邻接的地区中心中点比较正确位置,避免地图原点影响角度容差;困难档保留此前的平移/角度判定。远距靠拢仍只施加双向力,不旋转回正,不直接拖动已落地块。所有档位都保留合并试放、长大阻挡和防穿透检查。
|
||||
|
||||
弹性三档为“轻轻弹 / 蹦蹦跳 / 弹力全开”,碰撞恢复系数分别为 0.65 / 0.82 / 0.96,同时调整空气阻力、横向冲量和上抛上限。现在最低档对应上一版最高档;默认推荐“蹦蹦跳”,便于腾挪,“弹力全开”需要更好地控制节奏。省级、地级碎片均在上一版基础上线性再缩小 10%;地级仍比相同地图基准额外小 30%;组合过大时仍会全局等比缩放。
|
||||
每次合并分数 = `(100 + 40 × (合并后块数 − 2)) × 连合系数 × 特殊连接系数`,四舍五入到整数。8.5 秒内再次合并视为连合,系数每次增加 0.25,从 1 起、最高 2;跨海和隔区连接额外乘 0.8。通关再奖 `1000` 分。轻划不限次数、不扣分。吸附难度和弹性不改变得分倍率。
|
||||
|
||||
最佳纪录按“关卡 + 下落速度 + 弹性”分别保存,旧版纪录对应最低弹性。客户端 9.46.0+ 优先使用官方 Storage;含同步启动信息、异步回退、编译序号剥离及旧纪录迁移。旧环境回退到带异常处理的浏览器存储。
|
||||
弹性四档为“轻轻弹 / 蹦蹦跳 / 元气弹跳 / 弹力全开”,碰撞恢复系数分别为 0.65 / 0.82 / 0.90 / 0.96,同时调整空气阻力、横向冲量和上抛上限。新档“元气弹跳”的上抛、冲量和速度上限介于正常与全开之间;保留旧三档参数和纪录身份;默认推荐“蹦蹦跳”,便于腾挪,“弹力全开”需要更好地控制节奏。省级、地级碎片均在上一版基础上线性再缩小 10%;地级仍比相同地图基准额外小 30%;组合过大时仍会全局等比缩放。
|
||||
|
||||
成绩页展示可分享的 750×1400 图片:本局得分、结束时实际容器画面、关卡、速度和弹性、用时、最大拼合 / 最佳连合,以及得分随分钟变化的曲线。曲线从零开始,记录实际得分时刻和结束时间,包含通关奖励,暂停不计时。不会用完整地图冒充未完成的结局。
|
||||
最佳纪录按“关卡 + 吸附难度 + 弹性”分别保存;旧版随心落子纪录只归入“严丝合缝”及其原弹性档位,不混入较易难度;旧版定时纪录仍保留但不混入当前模式的入口最佳;战绩簿按难度汇总历史最高分,旧版无吸附档位的分数归入原难度。客户端 9.46.0+ 优先使用官方 Storage;含同步启动信息、异步回退、编译序号剥离及旧纪录迁移。旧环境回退到带异常处理的浏览器存储。
|
||||
|
||||
成绩页展示可分享的 750×1400 图片:本局得分、结束时实际容器画面、关卡、吸附难度和弹性、用时、最大拼合 / 最佳连合,以及得分随分钟变化的曲线。曲线从零开始,记录实际得分时刻和结束时间,包含通关奖励,暂停不计时。不会用完整地图冒充未完成的结局。
|
||||
|
||||
“发小红书”在玩家点击后调用 `writeTempFile` + `postNote`,携带图片和成绩文案打开图文发布页,玩家可编辑、取消、确认发布;不会自动发布或声称审核成功。“保存成绩图”调用 `writeTempFile` + `saveImageToPhotosAlbum`。无 SDK、接口缺失、授权拒绝、取消或调用失败都有提示,不丢失成绩图;临时路径即用即弃。
|
||||
|
||||
当前[官方文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc#s3-3)支持分享已有视频,但未提供公开录屏 API;屏幕共享被禁用。因此本包不含自动录屏,可用手机系统录屏后自行分享。未使用未公开原生 bridge 或网页屏幕采集。
|
||||
成绩页以斜线分隔的统一发布入口提供“发图文 / 发视频”。首次点击“发视频”会生成视频并继续打开发布编辑页,已有视频则直接复用;生成期间右侧变为“取消生成”。发布时用 `writeTempFile` 写入 MP4 与成绩图封面,再通过 `postNote({pageType: "video_publish"})` 打开视频笔记编辑页。玩家确认后发布;失败或取消保留生成的视频,重试时使用新临时路径。当前设备不支持 MP4 编码时提示并保留成绩图分享。
|
||||
|
||||
构建使用已随源码提供的 `src/maps.js`;`research/china.json` 是构建时地图完整性核对必需的源快照。其余 `research/` 文件和整个 `data/` 目录均不入库。下载与预处理脚本仅用于另行更新地图;重生成前需要准备 `research/areas.json` 等原始输入。
|
||||
视频来自整局游戏的轻量渲染状态:从开局到结束按30帧/秒采样图块位置、旋转、合并缩放、待落图块、碰撞效果与得分,暂停和后台不计入旅程时间;共享离线地图几何,不截取或压缩JPEG。结束后直接用同一套地图绘制器在720×1280画布重绘,以30帧/秒、目标3Mbps编码无声H.264 MP4,尾部附2秒真实成绩图。时间线不再限制为最后60秒。状态存储按32MiB估算预算控制,超预算时均匀降低整条时间线的运动采样率,始终保留开头与结尾,界面提示实际采样率;输出仍为30帧/秒,低采样率时重复最近状态,不伪称新增运动细节。输出48MiB安全上限是应用自身的内存保护,非平台配额;超限明确报错,不截断过程。仅在发布时短暂转换为原生接口要求的base64。不记录相机、麦克风、手机屏幕或通知,不调用屏幕共享。没有本地视频播放预览或网页下载,避免容器禁止的blob/data视频媒体源。
|
||||
|
||||
优先检测 `VideoEncoder.isConfigSupported` 的H.264能力,使用WebCodecs与随包离线编译的mp4-muxer 5.2.2生成标准MP4;检测失败或编码失败时,仅在 `MediaRecorder.isTypeSupported` 明确支持MP4时采用画布流备用编码。备用路径约需与整局时长相同的生成时间;超时预算随整局时长延长;不把WebM改后缀伪装为MP4。两条路径均可取消,切后台或离开成绩页会中止并释放编码器、视频轨道及画面引用。新开局或返回入口清除上一局素材。WebCodecs及MP4录制是可选增强,旧WebView保留游戏与成绩图,不承诺视频可用。
|
||||
|
||||
`npm run test:video` 生成两条路径的真实MP4并用ffprobe/ffmpeg核对H.264、尺寸、时长及完整解码,验证原生接口参数(模拟SDK)、发布失败重试、无SDK/无编码能力降级、取消/后台/新开局清理、60秒/6MiB采样边界与小屏布局。现代Chrome结果不能证明小红书容器可用;Android/iOS的实际编码权限、速度、内存和`video_publish`全链路均待扫码验证。
|
||||
|
||||
构建使用已随源码提供的 `src/maps.js`;`research/china.json` 是构建时全国地图完整性核对必需的源快照;`research/detail-map-index.json` 是7个细分关卡的源代码/名称/内外环数量及来源摘要,供构建审计使用。其余 `research/` 文件和整个 `data/` 目录均不入库。下载与预处理脚本仅用于另行更新地图;重生成前需要准备 `research/areas.json` 等原始输入。
|
||||
|
||||
## 代码与数据
|
||||
|
||||
- `src/core.js`:相邻关系、地理位置与角度吸合判断、计分、纪录清洗。
|
||||
- `src/game.js`:Matter.js 刚体、出片、组合、胜负、速度、弹性、容器滑动与上晃、得分时间点和尺寸变化。
|
||||
- `src/app.js`:入口 / 游戏 / 成绩页切换、Canvas 绘制、容器手势与键盘输入、暂停与结束、原生 Storage、发笔记和相册导出。
|
||||
- `src/app.js`:入口 / 游戏 / 成绩页切换、Canvas 游戏绘制、SVG 地图选关、难度弹窗、容器手势与键盘输入、暂停与结束、原生 Storage、发笔记和相册导出。
|
||||
- `src/result.js`:真实结局快照与得分曲线的成绩图绘制。
|
||||
- `src/video.js`:有界游戏画面采样、MP4编码能力检测、两条本地编码路径、取消及资源清理。
|
||||
- `src/index.html`、`src/style.css`:单入口、响应式布局、安全区和 Chrome 61 样式基线。
|
||||
- `src/maps.js`:构建期简化的省级 / 地级真实轮廓与预计算连接图。
|
||||
- `src/maps.js`:省级、地级、指定县区/县市/分区真实轮廓与连接图;有内环的区域包含构建期三角剖分碰撞网格。
|
||||
- `scripts/download-maps.py`、`scripts/download-provinces.py`:仅开发期下载公开地图;后者有台湾省 404 的如实记录。
|
||||
- `scripts/prepare-maps.py`:简化轮廓、区分行政级别、预计算邻接,输出正式数据和独立县级素材。
|
||||
- `scripts/prepare-maps.py`、`scripts/map_geometry.py`:共享投影、简化、邻接和标签布局,输出省级/地级数据及备用县级素材。
|
||||
- `scripts/prepare-taiwan.py`、`scripts/prepare-detail-maps.py`、`scripts/triangulate-holes.mjs`:将本地官方SHP素材转为县市输入,增加7个指定细分关卡;保留内部空洞,生成不填满内嵌城区的碰撞几何。Earcut 2.2.4仅作为构建依赖,不在游戏运行时加载。
|
||||
- `scripts/check.mjs`、`scripts/build.mjs`、`scripts/package.py`:ES2017 / 离线静态检查、官方 Skill 体积审计、ZIP 结构及完整性验证。
|
||||
- `tests/`:真实地图可通关性、错误边拒合、朝向一致性、计分、警戒线、整框惯性及入口 / 开局浏览器回归。
|
||||
|
||||
地图来源:阿里云 [DataV.GeoAtlas](https://datav.aliyun.com/portal/school/atlas/area_selector),2026-10-16 下载。数据可能落后于最新行政区划,已做游戏用轮廓简化,不能用于导航、测绘或行政区划查询。原始数据提供方条款继续适用,公开可下载不等于获得额外授权。视觉保留岛屿轮廓;物理碰撞对极小区域、退化或相交轮廓采用简化代理。
|
||||
全国、地级及四直辖市/港澳地图来源:阿里云 [DataV.GeoAtlas](https://datav.aliyun.com/portal/school/atlas/area_selector),2026-10-16 下载。数据可能落后于最新行政区划,已做游戏用轮廓简化,不能用于导航、测绘或行政区划查询。台湾县市补充来源:[国土测绘中心开放县市边界](https://data.gov.tw/dataset/7442),原始ZIP为COUNTY_MOI_1090820快照,[政府资料开放授权条款第1版](https://data.gov.tw/license)。原始数据提供方条款继续适用。视觉保留岛屿轮廓;物理碰撞对极小区域、退化或相交轮廓采用简化代理。
|
||||
|
||||
物理引擎 Matter.js 0.20.0、凹多边形分解 poly-decomp 0.3.0 随包本地提供,许可证见发布包 `licenses.json`。不用 AI、外部 SDK、远程字体、WASM、Worker、网络请求、剪贴板或文件下载能力。
|
||||
物理引擎 Matter.js 0.20.0、凹多边形分解 poly-decomp 0.3.0 随包本地提供,MP4封装依赖mp4-muxer 5.2.2一并编译随包提供,许可证见发布包 `licenses.json`。不用 AI、外部 SDK、远程字体、WASM、Worker、网络请求、剪贴板或文件下载能力。
|
||||
|
||||
## 检查记录
|
||||
|
||||
@@ -87,7 +110,7 @@ npm run test:swipes
|
||||
|
||||
## 容器晃动与碎片比例更新
|
||||
|
||||
棋盘改为较窄的开口实体容器,侧壁和底边清晰可见,红色警戒线位于顶部 78px。以 320×435 逻辑尺寸为例,全国开局缩放约 0.4995,相对最初约 0.215 的版本仍明显更大,本轮比上一版的0.555再缩小10%。保持各省之间真实比例,微小地区保持真实相对比例,同样通过框体晃动参与拼接。
|
||||
棋盘采用较窄的开口实体容器,侧壁和底边清晰可见,红色警戒线随容器高度在顶部62~120px适配。以320×435逻辑尺寸为例,全国开局缩放由0.4995调整到约0.4246,图块宽高再缩小15%。保持各省之间真实比例,微小地区保持真实相对比例,同样通过框体晃动参与拼接。
|
||||
|
||||
晃动采用阻尼弹簧模型:容器位移和施加给所有碎片的惯性来自同一状态,并有 14px 的视觉位移限制。在容器内滑动可朝任意方向施加冲量,力度随滑速增加,按与手指的距离平滑衰减;所有组合都受影响,近处更强。移动时只采集手势,在松手时识别一次轻划;使用最近约180ms轨迹,位移至少18px、平均速度至少0.16px/ms,慢拖和按住不触发。触发间隔至少240ms,系统取消或暂停清理尚未触发的手势。键盘左右键仍支持长按。无需加速度计或设备运动 API。
|
||||
|
||||
@@ -95,26 +118,157 @@ npm run test:swipes
|
||||
|
||||
## 碰撞与晃动手感
|
||||
|
||||
碰撞采用160ms轻微压缩回弹、淡轮廓与180ms小接触点,明显弱于合并时的1600ms反馈。渲染形变不修改真实碰撞几何。左右晃动缓存300ms颠起请求,每个物理步在实际接触时处理,避免150ms输入采样错过55ms接触窗口;每组仍有500ms颠起间隔,过期请求不在空中反复加速。暂停/离开页面清理待处理请求。上晃对框内全部组合施加一次向上冲量,420ms 冷却限制快速连点。颠起目标与上抛速度随弹性档位变化,但各档均有上限。最大倾角约 ±9.2°,水平和下落速度也受限,避免无限加速或飞出框。
|
||||
碰撞采用160ms轻微压缩回弹、淡轮廓与180ms小接触点,明显弱于合并时的1600ms反馈。渲染形变不修改真实碰撞几何。左右晃动缓存300ms颠起请求,每个物理步在实际接触时处理,避免150ms输入采样错过55ms接触窗口;每组仍有500ms颠起间隔,过期请求不在空中反复加速。暂停/离开页面清理待处理请求。上晃对框内全部组合施加一次向上冲量,420ms 冷却限制快速连点。颠起目标与上抛速度随弹性档位变化,但各档均有上限。地块自由旋转,不限制绝对角度、不自动回正;角速度上限为0.025弧度/物理步,水平和下落速度仍受限,避免无限加速或飞出框。
|
||||
|
||||
`tests/shake-preview.mjs` 用真实左右操作和虚拟时钟采集碰撞反馈、速度与截图;`tests/results.mjs` 检查上晃、暂停主动结束、曲线和图片生成、模拟 SDK 参数和失败降级、自然判负及小型关卡真实通关的结算流程。均为本地开发回归,不能替代平台模拟器或真机验收。
|
||||
|
||||
`artifacts/bounce-probe.json`记录320×590逻辑尺寸下的实际物理峰值:单块正方形、一次上晃,四种速度分别测得轻弹33~37px、正常97~110px、强弹236~270px。真实地图组合、容器尺寸和接触情况会改变结果。
|
||||
|
||||
省级/地级有滑块过渡及水平滑动手势;弹性选项压缩回弹,普通按钮轻按,目的地和弹窗渐入。减少动态效果设置会关闭装饰动画与碰撞形变,保留游戏物理。`npm run test:interactions`覆盖滑动切换、推荐默认档、按钮动画和真实碰撞反馈。
|
||||
全国/省区地图切换有轻微渐入,地图区域尺寸固定;弹性选项压缩回弹,普通按钮轻按,弹窗渐入。减少动态效果设置会关闭装饰动画与碰撞形变,保留游戏物理。`npm run test:interactions`覆盖地图钻入/返回、难度弹窗、推荐默认档、按钮动画和真实碰撞反馈。
|
||||
|
||||
## 手机布局与地图标签修正
|
||||
|
||||
顶部在状态栏安全区之外额外预留56px平台操作区。玩法说明移到入口页脚,游戏的旅程设置和暂停/结束移到底部,成绩页返回移入底部操作组;弹窗也避开平台顶部区域。
|
||||
|
||||
手机入口保留地图预览,并按设置面板剩余高度分配地图区域;省级展示全国、地级展示所选省区。短屏收紧间距,横屏地图与双栏设置并排,避免地图、面板和页脚互相挤压。
|
||||
手机入口以可点击的SVG地图为主体,保留所有省级轮廓、离岛和海域线,省区准备页展示真实地级轮廓。片数与地图组成同一场景;不再使用可变高度的版本滑块、省区表单或独立选择卡片。全国与省区场景、难度入口和开局按钮位置固定,横屏时地图与信息并排。
|
||||
|
||||
入口和游戏页均按可见视口及安全区排版:在320×568、375×667、390×844以及740×390横屏检查一屏显示;小屏会缩减装饰地图和冗余文案,保留所有主要选择与开局按钮。省区选择采用27项网格,不再使用原生长下拉框,弹窗注明无地级数据的范围。
|
||||
入口和游戏页均按可见视口及安全区排版:320×568、375×667、390×844以及740×390横屏同屏显示。吸附难度和弹性移入同一弹窗,短屏按媒体查询收紧弹窗间距;设置不改变底层地图或开局栏的位置。
|
||||
|
||||
四档名称为“佛系看山 / 说走就走 / 一路狂飙 / 山河起飞”,速度与倍率参数保持原值。底部仅保留无文字的圆形暂停图标,合并全部旅程操作,容器顶部说明标签已移除,成绩页同样一屏显示。游戏地图先绘制全部形状,再统一绘制标签;名称采用独立布局、边距约束、避让,以及小地区标签底色 / 引线,防止名称贴底被裁或被其他省份覆盖。
|
||||
已移除定时速度选择,当前所有关卡均固定为随心落子。底部仅保留无文字的圆形暂停图标,合并全部旅程操作,容器顶部说明标签已移除,成绩页同样一屏显示。游戏地图先绘制全部形状,再统一绘制标签;名称采用独立布局、边距约束、避让,以及小地区标签底色 / 引线,防止名称贴底被裁或被其他省份覆盖。
|
||||
|
||||
构建补回源数据的小离岛外环及独立海域线层,34省级代码、名称及外环数量均与保存的原始数据对比。台湾省14个源外环、香港9个、澳门2个、海南133个全部保留。详见`artifacts/MAP_AUDIT.md`,这不是标准地图审图结论。
|
||||
|
||||
`npm run test:container`在现代浏览器模拟平台顶部按钮与四组手机尺寸/安全区,逐页点击并检查布局,不替代小红书真机验证。
|
||||
|
||||
`tests/swipes.mjs` 使用真实触摸事件验证单次轻划松手触发、移动中不施力、慢拖和按住过滤、松手后的惯性运动、系统取消、滑动中暂停和恢复、圆形无文字菜单;物理单测另检查附近力度更强、远处仍动、快速更有力和三档边界限制。
|
||||
`tests/swipes.mjs` 使用真实触摸事件验证单次轻划松手触发、移动中不施力、慢拖和按住过滤、松手后的惯性运动、系统取消、滑动中暂停和恢复、圆形无文字菜单;物理单测另检查附近力度更强、远处仍动、快速更有力和四档边界限制。
|
||||
|
||||
`npm run test:atlas`逐一从全国地图钻入34个省区并返回,检查34个细分关卡可玩并按层级标注、全国直接开局、省区11块真实开局和难度生效;严格比较地图场景、开局栏及按钮的位置/尺寸,确保各次选择不改变高度。`tests/helpers.mjs`封装通过可见地图与弹窗选择关卡/难度,供浏览器回归复用。
|
||||
|
||||
`npm run test:staging`使用真实CDP触摸检查:首块预备、上方横向调整、∞等待/松手、取消/暂停、定时到点强制释放、提前松手不加速、旧手势隔离、倒计时冻结,以及五个速度选项/四个弹性选项顺序和成绩难度字段。物理测试另验证四档时限、预备块不入物理世界及轮廓边界。
|
||||
|
||||
## 图块比例与吸附微调(2026-10-17)
|
||||
|
||||
游戏内省级、地级图块在前版基础上统一缩小15%,待落区和下一块预览同步缩小。地块名称同比缩小,最小保留8px,描边随字体收细;入口封面SVG地图与其名称保持原尺寸。正确相邻组合的位置误差允许从22px放宽到26px,相对角度误差从约13.8°放宽到16°,仍检查真实邻接和正确方位,过远、贴错边或非邻接不会合并。
|
||||
|
||||
## 细分地图素材更新
|
||||
|
||||
原始素材保存在忽略的`data/provinces-raw/`和`data/taiwan-raw/`中,正式构建直接使用已生成的`src/maps.js`与紧凑审计索引,不需要联网下载。需要重新生成地图时,先准备原始输入并运行`python3 scripts/prepare-maps.py`;从[官方图资下载页](https://maps.nlsc.gov.tw/pro/download.jsp)下载县市SHP ZIP至`data/taiwan-raw/counties.zip`,再依次运行`python3 scripts/prepare-taiwan.py`、`python3 scripts/prepare-detail-maps.py`,最后`npm run build`。两个县市内嵌案例和天津两处内环都用静态三角网格保留空洞,SVG/Canvas使用偶奇填充,内嵌城市的源边界记为陆地邻接。
|
||||
|
||||
`test:atlas`检查34地区钻入/返回与固定几何尺寸,并在新增7关真实开局、暂停结束;`test:container`在手机/横屏/安全区下覆盖县区、县市、港澳分区。物理测试覆盖35个关卡529块及全部关卡可合并为整体,同时验证新北/台北、嘉义县/市的空洞碰撞。实际低端手机性能仍待扫码验证。
|
||||
|
||||
侧墙在 Matter 创建静态刚体后单独设为零摩擦,底部及地块间摩擦保持原值,避免不规则轮廓挂墙休眠。每100ms按位置和角度变化检查是否稳定,并用凸分片向下1.5px探测真实支撑链,保留地图空洞,不以复合包围盒充当支撑;没有底部支撑的休眠体会恢复下落。合并的边界修正只移动该组合,全局缩放仍统一作用于全部地块。`tests/overflow.test.cjs`覆盖真实江西90种贴墙姿态、窗口变化、悬空唤醒、多层休眠支撑、侧贴与空洞、无关地块位置;`shanheDebug.state()`含支撑/休眠/堆顶计时信息供偶发问题排查。
|
||||
|
||||
主动靠拢:已落地块在正确拼合位置的平移误差大于26px、不超过45px,且真实邻接、相对角度不超过约16°时,最近的不冲突配对受到轻微中心力,越近越强;不修改地块位置,不施加旋转,双向等大反向力按质量分配运动,限制沿拼合方向的助推速度,保留玩家惯性与物理碰撞。进入26px仍用原合并规则。预备块、暂停、远处、错向及非相邻地块不参与牵引。
|
||||
|
||||
地图块比例再次调整:PIECE_SIZE由0.85降至0.60,游戏内宽高为前版约70.6%、面积约49.8%;全国320px宽容器中的完整地图初始宽度从424.6px降至299.7px,内蒙古从199.1px降至140.6px。地级/县区/县市/分区关卡及待落、下一块轮廓同比缩小,地级相对全国的0.7比例保留;字体随比例调整但不低于8px。入口封面SVG保持原状。
|
||||
|
||||
大省与合并成长:全国关卡中,新疆、内蒙古、西藏、青海单独存在时再缩小8%,其他省区和细分关卡单块保持原比例。拼合判断使用统一比例的中心坐标,显示和碰撞共同使用每组实际比例;合并后按组合块数恢复统一比例并轻微长大(2块+2%、4块+4%、8块及以上最多+6%),320ms缓出动画逐步同步放大实际碰撞体与轮廓,角度保持自然状态。累计增长按块数计算,不逐次倍乘;先按最终大小检查容器边界,暂停冻结动画,缩放/横竖屏保留中间比例,通关截图直接使用最终尺寸。封面、源地图、计分及26/45px规则不变。
|
||||
|
||||
省域素材完整性由research/prefecture-map-index.json保留27组源子区域的代码、名称、类型及内外环计数,构建时逐一核对,防止省直辖地区再次被过滤。原始地图和对比截图仍保留在本地忽略目录。test:province-direct覆盖四个修复关卡的地图显示、新增地区真实释放、成绩片数及手机/横屏固定布局。
|
||||
|
||||
海南省域关卡:本岛放大,三沙诸岛全部源外环作为统一缩放、平移的示意插图保留;地图框及说明明确标注“缩放移位示意”,游戏碰撞与轮廓采用同一变换。全国海南轮廓、海域线和原始素材坐标保持原状。
|
||||
|
||||
合并、长大和入口防穿插:合并前试放候选碰撞体,检查边界适配后是否挤入无关地块;被占用时保留原块和得分,短暂延后重试。长大被挡时暂停,空间腾出后续播。入口被弹起的地块占住时,新块从其上方进入。连续碰撞用同一步的共同安全时间点限制深穿透;横竖屏变化采用统一缩放和平移,保留组合间距。吸附26px、磁吸45px和自由旋转规则不变。tests/overlap.test.cjs验证阻挡后的合并重试、增长暂停恢复、入口防重叠、高速接触、视口压缩及8组湖北45秒随机压力回归,并检查碎片仍持续运动。
|
||||
|
||||
|
||||
## 难度确认更新(2026-10-17)
|
||||
|
||||
开启旅程、暂停重开和成绩页再玩统一先进入确认弹窗。弹窗使用草稿选项,确认后才创建新对局;取消不会丢失原局、成绩或视频。成绩卡、暂停说明、完整游玩视频标题与分享文案均携带实际对局的吸附难度和弹性。`tests/difficulty.test.cjs`覆盖三档位置/角度、真实合并、地图原点不影响宽松判定、非邻接过滤、防占用合并、远距力、12种吸附/弹性组合手动释放和湖北压力场景。
|
||||
|
||||
本次页面验证通过CUA在独立浏览器页操作并保存于本地artifacts:全国/省域开局确认、取消草稿、暂停重开取消保留、结果页再玩确认、三档实际生效,以及手机与横屏弹窗。npm test包含物理回归;此前tests/*.mjs中的定时界面场景属于旧流程,不作为当前手动模式的验收结果。平台模拟器、Android/iOS与Chrome61实际兼容性仍需同包验证。
|
||||
|
||||
|
||||
## 留洞补位、回正开关与开局动效(2026-10-17)
|
||||
|
||||
轻松拼的位置/牵引范围从68/120px增强为84/148px,相对角度从约49°放宽到57°;刚刚好从42/78px增强为52/98px、约32°放宽到38°。两档牵引力及助推速度上限分别提高25%和12%;严丝合缝保持26/45px、约16°与此前牵引强度。
|
||||
|
||||
留洞补位按源地图轮廓的栅格补集洪泛判断封闭区域,包含源内环中的内嵌地区,避免把开口海湾或外侧省份当成缺口。合并组合会显示缺失轮廓;已落下的正确缺块靠近包围它的外圈、相对角度符合当前吸附难度且有真实陆地邻接时,允许跨越封口补入,不以全局进度放宽所有地块。靠近范围按距外圈轮廓的距离判断,为当前牵引范围的1.2倍加小幅轮廓半径补偿;目标仍经过第三方占位检查,被占用时保留原块和分数。480ms淡入和移动效果进入共享视频绘制器,得分与普通合并相同,成绩卡标记实际补位次数。
|
||||
|
||||
游戏底部暂停按钮旁提供“自动回正”滑动开关:每局默认关闭,可点击、左右滑动或用空格切换;不会触发游戏暂停或容器晃动。开启会唤醒所有现有组合,受控地朝地图正向转动,后续倾斜也会被纠正;关闭后不再施加回正控制。控制保留旋转速度上限和防穿透修正,拥挤时仍受实际碰撞限制,不强制瞬移角度。暂停冻结、横竖屏保留开关;新局重新关闭。成绩图标记本局是否用过回正辅助,视频按画面实际时刻显示开关状态。
|
||||
|
||||
开局、再玩或暂停重开的确认弹窗,根据发起按钮的真实位置和尺寸展开,时长380ms;设置弹窗保留普通进入方式。减少动态效果时不播放此展开动效或补位移动效果。
|
||||
|
||||
新增tests/pockets.test.cjs验证封闭/开口、内嵌城市、实际补位、距离/方向/邻接限制、第三块阻挡与真实湖北六邻省封口;tests/upright.test.cjs验证默认自由旋转、开启后多角度平滑回正、开关/暂停/缩放/结束边界,以及真实湖北连续晃动和反复切换的防穿透。独立预置测试页面位于本地忽略的artifacts/assist-fixture,只用于视觉复现,不进入正式ZIP或Git。
|
||||
|
||||
|
||||
难度确认弹窗在外框展开结束后,仍保持内容区与操作区无独立入场动画,避免清理外框动画class时再次触发screen-in。全部地区出场后的提示统一改为“山河已到齐,继续拼合”,下一块预览与视频文案同步调整。
|
||||
|
||||
|
||||
## v2.1.0
|
||||
|
||||
首页标题旁新增红色v2.1.0,package.json与锁文件版本同步。成绩页移除独立视频生成按钮,将“发图文 / 发视频”合并为一个绿色主操作区,中间以斜线分隔;原进度行继续显示视频状态,不增加页面高度。右侧首次点击完成生成后自动进入视频发布编辑页,需用户确认才发布;再次点击复用已有视频。取消、生成失败、SDK缺失、发布取消和失败均保留原有降级及重试逻辑;生成期间离开原局或打开重开确认时,不自动拉起旧局发布页。
|
||||
|
||||
自动回正恢复老版轻量控制:转速按0.84阻尼衰减、按倾角0.022回拉,采用Git 465fee1的原有物理解算后控制:超出±0.16弧度(9°)直接限角、转速乘0.2,接近正向时归零,不改平移速度;正常碰撞和防穿透先完成;限角单独只读检查当前空间,受阻时暂缓,不撤销已发生的平移或牵连其他块。开启时唤醒一次,后续不在每帧清零休眠计数,静止块可正常休眠;晃动/碰撞会按原物理规则唤醒。关闭保留自然转动。tests/upright.test.cjs覆盖默认关闭、多角度回正、老版摇摆与转速上限、休眠计数/落地休眠/晃动唤醒、暂停/缩放、真实江西/新疆落地及湖北堆叠防穿透和持续运动。tests/publish.test.cjs用实际发布函数和模拟SDK验证一次点击生成并打开编辑页、取消、旧局隔离、无SDK降级和发布失败复用,未向平台发布真实笔记。
|
||||
|
||||
自动回正切换仅提示“自动回正 · 开启/关闭”,120ms淡入、短暂停留后180ms淡出,总时长800ms;连续切换重启动效,其他错误或发布提示保留原有阅读时间。系统减少动态效果时不播放淡入淡出。
|
||||
|
||||
|
||||
## 本地战绩簿
|
||||
|
||||
地图导航行的“旅程战绩”进入完整战绩簿,包含全国1关、省内27关、县市区7关;按类别筛选,每页6关,列表内部滚动,首页主体与开局栏高度不变。每关展示历史最高分及三种吸附难度各自最高分,跨弹性档位取最高,零分已挑战与未挑战分开显示。每关每个吸附难度完整通关点亮一枚奖杯,共105枚;弹性、回正辅助不限,不重复发杯。主动结束和失败只保存分数。旧纪录没有通关标记,保留分数,不推断或补发旧奖杯。
|
||||
|
||||
新key shanhe-progress-v2将分数与通关状态放在同一JSON原子写入;首次读取合并原生/浏览器旧shanhe-records-v1与新进度,分数取高、奖杯取并集,保留旧key。加载期间的新成绩也并入,避免迟到数据覆盖。符合9.46版本及能力条件时使用官方Storage,否则兼容浏览器存储;读取失败时不覆盖未读缓存,保存失败提示当前页面暂存。本机缓存清理后可能丢失,不提供跨设备排行榜。tests/records.test.cjs覆盖35关105杯、迁移、隔离、失败与异步加载竞争。
|
||||
|
||||
|
||||
自动回正性能修正(v2.2.0):旋转弹簧曾在每帧调用Sleeping.set(false),导致复杂地块无法累积休眠时间,拥挤堆叠持续参与碰撞;现已回到老版阻尼回拉和小幅摇摆。开关、短提示、视频/成绩辅助标记保持原样。Node物理对比与浏览器仅用于开发定位,不代表真机性能验收。
|
||||
|
||||
## 地级行政区下辖县区 GIS 素材
|
||||
|
||||
运行 `python3 scripts/fetch-city-county-gis.py --snapshot 2026-10-18` 获取并校验现有333个地级行政区对应的素材,输出到忽略的 `data/city-county-gis/2026-10-18/`,不改写正式 `src/maps.js`。支持 `--only 330100,441900` 小范围核验;同一快照目录内可复用已校验下载,更新快照使用新的日期目录。
|
||||
|
||||
2026-10-18重新获取结果为329组县区细分、2728块,另有东莞/中山/儋州/嘉峪关4份整市轮廓;这些城市的DataV整市属性为childrenNum=0、区县子级接口404,不能伪造县区,镇街边界需另行获取。原始GeoJSON保存于raw/及unsplit/,每市简化拼图坐标保存于levels/,manifest.json记录父级、名称代码、源URL、原始及简化文件SHA256、全部外环/内环数量与例外。坐标基准未由源接口声明,原始坐标原样保留;levels/使用游戏平面坐标,不作为经纬度GIS数据。
|
||||
|
||||
素材ZIP为 `artifacts/city-county-gis-20261018.zip`,包含664个文件、约8.6MiB,与可上传小工具ZIP分开。来源4389个外环、3个内环全部保留,父子关系、重复ID、坐标有限值、闭环/非零面积及ZIP CRC和逐文件字节一致性检查通过;这不代替权威最新区划、拓扑和关卡可玩性核验。该ZIP是原始/预处理素材,不是小工具游戏包;正式游戏接入情况见下一节。原始素材、简化预备数据、ZIP与报告均由现有.gitignore排除。
|
||||
|
||||
|
||||
## 地级市县区关卡接入
|
||||
|
||||
已开放329个地级行政区的县区拼图(2728块),与原35关合计364关。进入省域地图后,点地级市进入县区准备页,也可直接从省域地图开启原省域拼图;县区页返回省域,再返回全国,导航栏和地图/开局栏高度保持不变。东莞、中山、儋州、嘉峪关的地图点击会说明没有县区子级,不虚构镇街边界。省直辖县级地区和已有直辖市/港澳台细分关卡保持原层级。县区密集地名采用避让与引线标注。
|
||||
|
||||
新增src/county-index.js仅保存目录和块数;src/atlas.js通过经典script按需加载包内county/下的静态文件,不调用网页网络API,不引用外部资源。源文件仍为329份单关素材,构建时按同省每两关合成173个脚本,发行目录同步重映射加载路径。首次进入不解析329份图形;同文件并发合并请求,最多缓存最近3个县区关卡,失败可重试,快速切换/返回使旧请求失效。成绩图、视频、暂停与重开使用当前城市关卡及省区名。战绩簿收录全部364关,按省区筛选和原分类分页,最多1092枚通关奖杯,分数与奖杯使用现有独立关卡ID存储。
|
||||
|
||||
发布数据已做保持拓扑的简化和坐标精度选择,保留独立岛屿与内环;13处源多边形自交由GEOS修复,来源和修复后的外环/内环数量记录在research/county-map-index.json。8个含内环的地区预置三角碰撞片,额外1个会使动态凸分解达到递归上限的地区预置静态碰撞片;不通过把非凸边界改成凸包覆盖邻区。
|
||||
|
||||
329份src/county/*.js是运行/编译需要的地图素材,连同小型目录与构建核对用来源索引进入代码库;原始GeoJSON、完整素材ZIP、工具环境和报告仍忽略。npm run build直接使用保存好的素材,新增审计核对全部333个地级入口的329可玩+4例外覆盖、父级和名称、文件SHA256、简单边界、内环碰撞与关卡连通性,不需下载GIS或安装Shapely。
|
||||
|
||||
重新准备素材时可执行(仅准备步骤需要Shapely):
|
||||
|
||||
```sh
|
||||
python3 -m venv .cache/gis-runtime
|
||||
.cache/gis-runtime/bin/python -m pip install -r scripts/requirements-gis.txt
|
||||
.cache/gis-runtime/bin/python scripts/prepare-county-levels.py --snapshot 2026-10-18
|
||||
node scripts/triangulate-county-holes.mjs
|
||||
npm run build
|
||||
```
|
||||
|
||||
新增tests/county-levels.test.cjs覆盖329关全部图块物理生成及正确拼合通关、县区/省域纪录隔离、三级导航与异步竞争、并发加载/失败重试/缓存淘汰,及杭州/楚雄/内嵌地区的持续晃动物理回归。普通浏览器仅开发核验;平台模拟器、Android/iOS同ZIP、Chrome61和低端机性能仍需实际验证。
|
||||
|
||||
|
||||
## v2.2.0 回正与物理热点优化
|
||||
|
||||
回正开关保留465fee1的阻尼/回拉/约9度摇摆手感,正常碰撞及防穿透先完成,再单独尝试本块限角/近零归零。空间被邻块或容器挡住时暂缓限角,继续保留平移和转速;腾出空间后再限角。仅对正在运动的地块应用,开启只唤醒一次。关闭时保留自由旋转。
|
||||
|
||||
防穿透二分回放改用只读刚体变换和凸片SAT投影,与Matter的2px穿透阈值一致,不生成接触点、不在每次试探中改写整堆顶点;只在最后提交一次安全姿态。保守扫掠包围框覆盖整个平移/转动路径,可能相遇的组仍共享同一个安全时刻,远处自由运动的地块不陪同回放。静止接触结果按位置/角度/缩放/包围框缓存,几何变化立即重新检查;WeakMap不保留已移除图块。
|
||||
|
||||
吸附缓存不可变组成员的邻接边,三档难度计算复用该边,合并扫描每组只算一次姿态;非邻接对不再重复扫边、检查缺口。组变化或边表替换后重新核对。吸附/磁吸的距离、角度、强度、得分和补位规则不变。
|
||||
|
||||
新增tests/physics-hotpaths.test.cjs用1200个插值位置与真实Matter碰撞对照,验证只读回放、最终一次提交、远处块不回退、静止缓存失效和吸附三档结果一致。scripts/profile-physics.cjs可复现全国/湖北/晋城县区在回正开关和晃动下的12个Node物理场景,输出平均/p95耗时、方法耗时、几何变更数与碰撞次数。计时含插桩开销,不含渲染/视频,不作为手机帧率或平台兼容性验收。
|
||||
|
||||
|
||||
回正锁堆修正:此前把强制限角和普通物理运动一起送入防穿透回放,转正位置被邻块占据时,平移也被整帧退回。现将可选回正姿态检查移到防穿透之后,使用只读凸片SAT核对本块目标与容器/邻块,不安全就保留当前位置/倾角,只通过后续物理转速逐渐回正。新增固定复现:倾斜长条旁有障碍,原控制把2px横移退回到约0.46px;修正后横移大于1.8px,阻挡期间不硬改角度,障碍移开后可正常回正。保留全部防穿透阈值,不通过允许重叠或冻结图块规避问题。
|
||||
|
||||
|
||||
## 动画停止的诊断与保护
|
||||
|
||||
物理受阻和整个画面停止是两个问题,尚未复现用户成都19/20块时的整画面停止,不能用物理回放优化作为已解决整页卡死的证据。成都12组80秒随机物理对局均未抛出异常,不包含浏览器渲染/视频开销。
|
||||
|
||||
动画帧分阶段记录物理/绘制/视频/界面,board的data-loop-health每500ms写一次紧凑本地诊断(帧数、耗时、关卡、回正状态与异常栈),不联网、不读取个人存储。下一帧统一在finally中按游戏状态调度;物理/绘制/UI报错会保留本局并显示可重试暂停界面,不再无提示终止RAF链。可选视频采样报错时停用该局视频并释放资源,主游戏继续;无视频会话的结算不再一直显示整理中。tests/frame-loop.test.cjs注入四个阶段的故障验证对应行为。
|
||||
|
||||
|
||||
## v2.2.0 单次回正(当前行为)
|
||||
|
||||
已移除自动回正滑动开关、持续控制器及其每帧调用,替换为暂停旁的“回正一次”胶囊按钮。一次点击只快照处理现有活动组合,直接设置正向0度并清除该次旋转速度,保留平移速度;没有落块、暂停或结束时不可执行。回正后即可自由翻转,后续新块/合并组不继承辅助,没有队列、后台重试或常驻限角。
|
||||
|
||||
按用户要求,按钮不提前试算碰撞、不避让、不挪位置、不因邻块阻挡而跳过,直接把所有当前组合角度设为0。后续由正常物理引擎继续处理接触。按钮图标有短促回摆动效。每次有效点击记录uprightCount,暂停页、成绩图和视频显示“回正N次”;已有uprightUsed辅助标记继续兼容,分数/奖杯规则不变。
|
||||
|
||||
测试覆盖直接全量正向、保持位置/平移速度、不调用碰撞试算、新块与之后自由转动、无后台重试、贴地不挪动、暂停/结束/尺寸变化以及真实湖北/成都多轮晃动的物理推进。历史章节中的自动模式仅为先前版本记录,以本节和当前源码为准。
|
||||
|
||||
Generated
+34
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "shanhe-puzzle",
|
||||
"version": "1.0.0",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shanhe-puzzle",
|
||||
"version": "1.0.0",
|
||||
"version": "2.2.0",
|
||||
"dependencies": {
|
||||
"matter-js": "0.20.0",
|
||||
"mp4-muxer": "5.2.2",
|
||||
"poly-decomp": "0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"earcut": "2.2.4",
|
||||
"esbuild": "0.25.12",
|
||||
"playwright": "1.56.1"
|
||||
}
|
||||
@@ -459,6 +461,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz",
|
||||
"integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/wicg-file-system-access": {
|
||||
"version": "2020.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
|
||||
"integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -472,6 +486,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -535,6 +556,17 @@
|
||||
"integrity": "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mp4-muxer": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
|
||||
"integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
|
||||
"deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "^0.1.6",
|
||||
"@types/wicg-file-system-access": "^2020.9.5"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shanhe-puzzle",
|
||||
"version": "1.0.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"description": "山河拼图 · 离线中国行政区下落合并小游戏",
|
||||
"scripts": {
|
||||
@@ -12,15 +12,22 @@
|
||||
"test:results": "node tests/results.mjs",
|
||||
"test:interactions": "node tests/interactions.mjs",
|
||||
"test:container": "node tests/container-layout.mjs",
|
||||
"test:swipes": "node tests/swipes.mjs"
|
||||
"test:swipes": "node tests/swipes.mjs",
|
||||
"test:atlas": "node tests/atlas.mjs",
|
||||
"test:staging": "node tests/staging.mjs",
|
||||
"test:video": "node tests/video.mjs",
|
||||
"test:celebrations": "node tests/celebrations.mjs",
|
||||
"test:province-direct": "node tests/province-direct.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"matter-js": "0.20.0",
|
||||
"mp4-muxer": "5.2.2",
|
||||
"poly-decomp": "0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.12",
|
||||
"acorn": "8.15.0",
|
||||
"earcut": "2.2.4",
|
||||
"esbuild": "0.25.12",
|
||||
"playwright": "1.56.1"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,864 @@
|
||||
{
|
||||
"downloadDate": "2026-10-17",
|
||||
"levels": [
|
||||
{
|
||||
"id": "110000",
|
||||
"name": "北京市",
|
||||
"kind": "county",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "7a333e5737d1e69a7e90400874ec908d5cb5c6a8d8efead24c0f137345054d95",
|
||||
"regions": [
|
||||
{
|
||||
"id": "110101",
|
||||
"name": "东城区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110102",
|
||||
"name": "西城区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110105",
|
||||
"name": "朝阳区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110106",
|
||||
"name": "丰台区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110107",
|
||||
"name": "石景山区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110108",
|
||||
"name": "海淀区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110109",
|
||||
"name": "门头沟区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110111",
|
||||
"name": "房山区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110112",
|
||||
"name": "通州区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110113",
|
||||
"name": "顺义区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110114",
|
||||
"name": "昌平区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110115",
|
||||
"name": "大兴区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110116",
|
||||
"name": "怀柔区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110117",
|
||||
"name": "平谷区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110118",
|
||||
"name": "密云区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "110119",
|
||||
"name": "延庆区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "120000",
|
||||
"name": "天津市",
|
||||
"kind": "county",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "9626c700abca708da272e03879ad98db4f1385fad11b8877481b13a0c2ef86db",
|
||||
"regions": [
|
||||
{
|
||||
"id": "120101",
|
||||
"name": "和平区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120102",
|
||||
"name": "河东区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120103",
|
||||
"name": "河西区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120104",
|
||||
"name": "南开区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120105",
|
||||
"name": "河北区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120106",
|
||||
"name": "红桥区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120110",
|
||||
"name": "东丽区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120111",
|
||||
"name": "西青区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120112",
|
||||
"name": "津南区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120113",
|
||||
"name": "北辰区",
|
||||
"rings": 1,
|
||||
"holes": 1
|
||||
},
|
||||
{
|
||||
"id": "120114",
|
||||
"name": "武清区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120115",
|
||||
"name": "宝坻区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120116",
|
||||
"name": "滨海新区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120117",
|
||||
"name": "宁河区",
|
||||
"rings": 1,
|
||||
"holes": 1
|
||||
},
|
||||
{
|
||||
"id": "120118",
|
||||
"name": "静海区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "120119",
|
||||
"name": "蓟州区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "310000",
|
||||
"name": "上海市",
|
||||
"kind": "county",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "f699513d5eb63d56350018d90db92345aeefc88c9e4f76197ea405178b6c31ae",
|
||||
"regions": [
|
||||
{
|
||||
"id": "310101",
|
||||
"name": "黄浦区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310104",
|
||||
"name": "徐汇区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310105",
|
||||
"name": "长宁区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310106",
|
||||
"name": "静安区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310107",
|
||||
"name": "普陀区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310109",
|
||||
"name": "虹口区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310110",
|
||||
"name": "杨浦区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310112",
|
||||
"name": "闵行区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310113",
|
||||
"name": "宝山区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310114",
|
||||
"name": "嘉定区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310115",
|
||||
"name": "浦东新区",
|
||||
"rings": 3,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310116",
|
||||
"name": "金山区",
|
||||
"rings": 4,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310117",
|
||||
"name": "松江区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310118",
|
||||
"name": "青浦区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310120",
|
||||
"name": "奉贤区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "310151",
|
||||
"name": "崇明区",
|
||||
"rings": 5,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "500000",
|
||||
"name": "重庆市",
|
||||
"kind": "county",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "ab7c6769ee2c8941054b71d82261eae5385e237354aea1887e00c416872c457d",
|
||||
"regions": [
|
||||
{
|
||||
"id": "500101",
|
||||
"name": "万州区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500102",
|
||||
"name": "涪陵区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500103",
|
||||
"name": "渝中区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500104",
|
||||
"name": "大渡口区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500105",
|
||||
"name": "江北区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500106",
|
||||
"name": "沙坪坝区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500107",
|
||||
"name": "九龙坡区",
|
||||
"rings": 3,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500108",
|
||||
"name": "南岸区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500109",
|
||||
"name": "北碚区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500110",
|
||||
"name": "綦江区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500111",
|
||||
"name": "大足区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500112",
|
||||
"name": "渝北区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500113",
|
||||
"name": "巴南区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500114",
|
||||
"name": "黔江区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500115",
|
||||
"name": "长寿区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500116",
|
||||
"name": "江津区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500117",
|
||||
"name": "合川区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500118",
|
||||
"name": "永川区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500119",
|
||||
"name": "南川区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500120",
|
||||
"name": "璧山区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500151",
|
||||
"name": "铜梁区",
|
||||
"rings": 3,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500152",
|
||||
"name": "潼南区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500153",
|
||||
"name": "荣昌区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500154",
|
||||
"name": "开州区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500155",
|
||||
"name": "梁平区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500156",
|
||||
"name": "武隆区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500229",
|
||||
"name": "城口县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500230",
|
||||
"name": "丰都县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500231",
|
||||
"name": "垫江县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500233",
|
||||
"name": "忠县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500235",
|
||||
"name": "云阳县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500236",
|
||||
"name": "奉节县",
|
||||
"rings": 3,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500237",
|
||||
"name": "巫山县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500238",
|
||||
"name": "巫溪县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500240",
|
||||
"name": "石柱土家族自治县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500241",
|
||||
"name": "秀山土家族苗族自治县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500242",
|
||||
"name": "酉阳土家族苗族自治县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "500243",
|
||||
"name": "彭水苗族土家族自治县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "810000",
|
||||
"name": "香港特别行政区",
|
||||
"kind": "partition",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "69ae74ef15e964508e6619763affbbff669295e23db0ab487350b7a060aa2bc9",
|
||||
"regions": [
|
||||
{
|
||||
"id": "810001",
|
||||
"name": "中西区",
|
||||
"rings": 3,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810002",
|
||||
"name": "湾仔区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810003",
|
||||
"name": "东区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810004",
|
||||
"name": "南区",
|
||||
"rings": 6,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810005",
|
||||
"name": "油尖旺区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810006",
|
||||
"name": "深水埗区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810007",
|
||||
"name": "九龙城区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810008",
|
||||
"name": "黄大仙区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810009",
|
||||
"name": "观塘区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810010",
|
||||
"name": "荃湾区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810011",
|
||||
"name": "屯门区",
|
||||
"rings": 8,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810012",
|
||||
"name": "元朗区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810013",
|
||||
"name": "北区",
|
||||
"rings": 25,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810014",
|
||||
"name": "大埔区",
|
||||
"rings": 14,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810015",
|
||||
"name": "西贡区",
|
||||
"rings": 59,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810016",
|
||||
"name": "沙田区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810017",
|
||||
"name": "葵青区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "810018",
|
||||
"name": "离岛区",
|
||||
"rings": 29,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "820000",
|
||||
"name": "澳门特别行政区",
|
||||
"kind": "partition",
|
||||
"source": "DataV.GeoAtlas",
|
||||
"sha256": "54a6c69e0007640b25d45b44357037fb40bce2e0e36e7a9026ebcbdfa7cfb19a",
|
||||
"regions": [
|
||||
{
|
||||
"id": "820001",
|
||||
"name": "花地玛堂区",
|
||||
"rings": 2,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820002",
|
||||
"name": "花王堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820003",
|
||||
"name": "望德堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820004",
|
||||
"name": "大堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820005",
|
||||
"name": "风顺堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820006",
|
||||
"name": "嘉模堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820007",
|
||||
"name": "路凼填海区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "820008",
|
||||
"name": "圣方济各堂区",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "710000",
|
||||
"name": "台湾省",
|
||||
"kind": "county-city",
|
||||
"source": "NLSC",
|
||||
"snapshot": "COUNTY_MOI_1090820",
|
||||
"translatedOffshoreRings": 16,
|
||||
"sourceURL": "https://data.gov.tw/dataset/7442",
|
||||
"license": "政府资料开放授权条款第1版",
|
||||
"sha256": "fdbb539c6658405c8a81560a947a16cb9f9bb98241c272a9a3d9c844dc4898c8",
|
||||
"regions": [
|
||||
{
|
||||
"id": "TW-10002",
|
||||
"name": "宜兰县",
|
||||
"rings": 102,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10007",
|
||||
"name": "彰化县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10008",
|
||||
"name": "南投县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10009",
|
||||
"name": "云林县",
|
||||
"rings": 6,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10017",
|
||||
"name": "基隆市",
|
||||
"rings": 7,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-63000",
|
||||
"name": "台北市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-65000",
|
||||
"name": "新北市",
|
||||
"rings": 1,
|
||||
"holes": 1
|
||||
},
|
||||
{
|
||||
"id": "TW-66000",
|
||||
"name": "台中市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-67000",
|
||||
"name": "台南市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-68000",
|
||||
"name": "桃园市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10005",
|
||||
"name": "苗栗县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10020",
|
||||
"name": "嘉义市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10010",
|
||||
"name": "嘉义县",
|
||||
"rings": 9,
|
||||
"holes": 1
|
||||
},
|
||||
{
|
||||
"id": "TW-64000",
|
||||
"name": "高雄市",
|
||||
"rings": 17,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10014",
|
||||
"name": "台东县",
|
||||
"rings": 10,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10015",
|
||||
"name": "花莲县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10016",
|
||||
"name": "澎湖县",
|
||||
"rings": 296,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10018",
|
||||
"name": "新竹市",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10004",
|
||||
"name": "新竹县",
|
||||
"rings": 1,
|
||||
"holes": 0
|
||||
},
|
||||
{
|
||||
"id": "TW-10013",
|
||||
"name": "屏东县",
|
||||
"rings": 10,
|
||||
"holes": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict'),{createHash}=require('node:crypto'),decomp=require('poly-decomp');
|
||||
const index=JSON.parse(fs.readFileSync('research/county-map-index.json','utf8')),context={window:{}};vm.runInNewContext(fs.readFileSync('dist/county-index.js','utf8'),context);const catalog=context.window.COUNTY_INDEX;
|
||||
assert.equal(index.levels.length,329);assert.equal(catalog.levels.length,329);assert.equal(index.unavailable.length,4);assert.deepEqual(Array.from(catalog.unavailable,l=>l.id).sort(),['441900','442000','460400','620200']);
|
||||
const parents=JSON.parse(fs.readFileSync('research/prefecture-map-index.json','utf8')).levels.flatMap(p=>p.regions.filter(r=>r.unitType==='prefecture').map(r=>({...r,province:p.id})));
|
||||
assert.deepEqual(index.levels.concat(index.unavailable).map(l=>l.id).sort(),parents.map(l=>l.id).sort());for(const entry of index.levels.concat(index.unavailable)){const parent=parents.find(p=>p.id===entry.id);assert.equal(entry.name,parent.name);assert.equal(entry.province,parent.province);}
|
||||
const packages=new Map();for(const meta of catalog.levels){assert.ok(/^\.\/county\/\d{6}\.js$/.test(meta.path));if(!packages.has(meta.path))packages.set(meta.path,[]);packages.get(meta.path).push(meta);}
|
||||
assert.deepEqual(fs.readdirSync('dist/county').sort(),Array.from(packages.keys(),p=>p.split('/').pop()).sort());const packedLevels=new Map();for(const [path,metas] of packages){assert.ok(metas.length>=1&&metas.length<=2);assert.equal(new Set(metas.map(m=>m.province)).size,1);metas.sort((a,b)=>a.id.localeCompare(b.id));assert.equal(path,'./county/'+metas[0].id+'.js');const text=fs.readFileSync('dist/'+path.slice(2),'utf8');assert.equal(text,metas.map(m=>fs.readFileSync('src/county/'+m.id+'.js','utf8')).join('\n'));const local=[];vm.runInNewContext(text,{window:{PuzzleAtlas:{register:value=>{assert.ok(!packedLevels.has(value.id));packedLevels.set(value.id,value);local.push(value.id);}}}});assert.deepEqual(local,metas.map(m=>m.id));}
|
||||
assert.equal(packedLevels.size,329);let pieces=0,rings=0,holes=0,repairs=0;
|
||||
for(const entry of index.levels){
|
||||
const text=fs.readFileSync('src/county/'+entry.id+'.js','utf8');assert.equal(createHash('sha256').update(text).digest('hex'),entry.assetSha256);
|
||||
const level=packedLevels.get(entry.id),meta=catalog.levels.find(l=>l.id===entry.id);assert.ok(meta);assert.equal(meta.count,entry.regions.length);assert.equal(level.province,entry.province);assert.equal(level.kind,'county');assert.equal(level.regions.length,entry.regions.length);assert.equal(level.id,entry.id);
|
||||
const seen=new Set();for(const r of level.regions){const source=entry.regions.find(v=>v.id===r.id);assert.ok(source);assert.ok(!seen.has(r.id));seen.add(r.id);assert.equal(r.name,source.name);assert.equal(r.rings.length,source.rings);assert.equal((r.holes||[]).length,source.holes);repairs+=source.repairedSourcePolygons;
|
||||
for(const ring of r.rings.concat(r.holes||[])){assert.ok(ring.length>=3);assert.ok(ring.every(p=>p.length===2&&p.every(Number.isFinite)));assert.ok(decomp.isSimple(ring),r.name+' must have a simple boundary');let area=0;for(let i=0;i<ring.length;i++)area+=ring[i][0]*ring[(i+1)%ring.length][1]-ring[(i+1)%ring.length][0]*ring[i][1];assert.ok(Math.abs(area)>1e-10);}
|
||||
if((r.holes&&r.holes.length)||(entry.collisionFallbacks||[]).includes(r.id))assert.ok(r.physics&&r.physics.length);pieces++;rings+=r.rings.length;holes+=(r.holes||[]).length;
|
||||
}
|
||||
// Every county participates in a connected puzzle, including marked island routes.
|
||||
const connected=new Set([0]);for(let i=0;i<level.regions.length;i++)for(const [a,b,type] of level.edges){assert.ok(a>=0&&b>=0&&a<level.regions.length&&b<level.regions.length);assert.ok(type>=0&&type<=2);if(connected.has(a)||connected.has(b)){connected.add(a);connected.add(b);}}assert.equal(connected.size,level.regions.length,level.name+' puzzle must be connected');
|
||||
}
|
||||
assert.equal(pieces,2728);const report={passed:true,stages:329,countyFiles:packages.size,pieces,rings,holes,repairedSourcePolygons:repairs,exceptions:index.unavailable,scope:'Frozen provider coverage, source names/IDs, topology repairs, static collision holes and all local assets; not current authoritative administrative validation.'};fs.writeFileSync('artifacts/county-map-audit.json',JSON.stringify(report,null,2));console.log('县区关卡核对通过:329关、2728块、'+packages.size+'个县区文件,本地文件/来源索引/父级/边界拓扑/内环碰撞/关卡连通性完整。');
|
||||
+22
-1
@@ -1,5 +1,5 @@
|
||||
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
|
||||
const context={window:{}};vm.runInNewContext(fs.readFileSync('src/maps.js','utf8'),context);const data=context.window.MAP_DATA,level=data.levels[0],source=JSON.parse(fs.readFileSync('research/china.json','utf8'));
|
||||
const context={window:{}};assert.equal(fs.readFileSync('dist/maps.js','utf8'),fs.readFileSync('src/maps.js','utf8'),'地图源素材与构建产物必须一致,请在地图生成完成后重新构建');vm.runInNewContext(fs.readFileSync('dist/maps.js','utf8'),context);const data=context.window.MAP_DATA,level=data.levels[0],source=JSON.parse(fs.readFileSync('research/china.json','utf8'));
|
||||
const expected=['110000','120000','130000','140000','150000','210000','220000','230000','310000','320000','330000','340000','350000','360000','370000','410000','420000','430000','440000','450000','460000','500000','510000','520000','530000','540000','610000','620000','630000','640000','650000','710000','810000','820000'];
|
||||
assert.deepEqual(Array.from(level.regions,r=>r.id).sort(),expected.slice().sort());
|
||||
const regions=level.regions.map(r=>{const f=source.features.find(f=>String(f.properties.adcode)===r.id),raw=f.geometry.type==='MultiPolygon'?f.geometry.coordinates:[f.geometry.coordinates];assert.equal(r.name,f.properties.name);assert.equal(r.rings.length,raw.length,r.name+'不得丢弃独立外环');for(const ring of r.rings){assert.ok(ring.length>=3);assert.ok(ring.every(p=>p.length===2&&p.every(Number.isFinite)));let area=0;for(let i=0;i<ring.length;i++)area+=ring[i][0]*ring[(i+1)%ring.length][1]-ring[(i+1)%ring.length][0]*ring[i][1];assert.ok(Math.abs(area)>1e-10,r.name+'存在退化外环');}return {id:r.id,name:r.name,sourcePolygonCount:raw.length,packagedPolygonCount:r.rings.length};});
|
||||
@@ -7,3 +7,24 @@ const maritime=source.features.find(f=>f.properties.adcode==='100000_JD');assert
|
||||
assert.ok(level.edges.some(e=>e[2]===1&&(level.regions[e[0]].id==='710000'||level.regions[e[1]].id==='710000')));
|
||||
const report={passed:true,provinceCount:34,regions,maritimeSegments:level.decorations.length,taiwanIncluded:true,hongKongIncluded:true,macaoIncluded:true,countyAndPrefectureLimitation:'台湾省地级轮廓接口404;无对应地级关卡。直辖市、港澳不设地级单元关卡。',scope:'验证34项名称/代码及源数据外环未丢失,不等于标准地图审图或所有最新区划/全部岛礁的独立权威核验。',mapContentReference:'https://www.gov.cn/gongbao/content/2023/content_5752310.htm'};
|
||||
fs.writeFileSync('artifacts/map-audit.json',JSON.stringify(report,null,2));console.log('地图核对通过:34省级行政区,含台湾/香港/澳门;全部源外环保留,海域线层'+level.decorations.length+'段。');
|
||||
// A province endpoint lists all immediate children. Audit the frozen source
|
||||
// list, including province-administered county-level peers, not only counts.
|
||||
const provinceIndex=JSON.parse(fs.readFileSync('research/prefecture-map-index.json','utf8'));
|
||||
assert.equal(provinceIndex.levels.length,27);let provinceDirectPieces=0,prefecturePieces=0;
|
||||
for(const entry of provinceIndex.levels){
|
||||
const lv=data.levels.find(l=>l.id===entry.id);assert.ok(lv&&lv.kind==='prefecture');
|
||||
assert.deepEqual(Array.from(lv.regions,r=>r.id).sort(),entry.regions.map(r=>r.id).sort(),entry.name+'不得过滤省直辖地区或遗漏源子区域');
|
||||
assert.equal(new Set(lv.regions.map(r=>r.id)).size,lv.regions.length);
|
||||
for(const r of lv.regions){const expected=entry.regions.find(v=>v.id===r.id);assert.equal(r.name,expected.name);assert.equal(r.unitType,expected.unitType);assert.equal(r.rings.length,expected.rings,r.name+'源外环必须保留');assert.equal((r.holes||[]).length,expected.holes,r.name+'源内环必须保留');if(r.unitType==='province-direct')provinceDirectPieces++;else prefecturePieces++;for(const ring of r.rings.concat(r.holes||[]))assert.ok(ring.length>=3&&ring.every(p=>p.every(Number.isFinite)));if(r.holes&&r.holes.length)assert.ok(r.physics&&r.physics.length);}
|
||||
const direct=entry.regions.filter(r=>r.unitType==='province-direct').length;
|
||||
assert.equal(lv.directRegionCount||0,direct);if(direct){assert.equal(lv.playLabel,'省域拼图');assert.ok(lv.unitCaption.includes('省直辖'));}
|
||||
}
|
||||
assert.equal(prefecturePieces,333);assert.equal(provinceDirectPieces,30);
|
||||
const hainan=data.levels.find(l=>l.id==='460000');assert.ok(hainan.mapInsets&&hainan.mapInsets.length===1);assert.equal(hainan.mapInsets[0].id,'460300');assert.ok(hainan.mapInsets[0].label.includes('缩放移位示意'));assert.ok(hainan.scopeNote.includes('三沙'));
|
||||
report.prefectureLevels=27;report.prefecturePieces=prefecturePieces;report.provinceDirectPieces=provinceDirectPieces;report.provinceChildPieces=prefecturePieces+provinceDirectPieces;
|
||||
console.log('省内完整性核对通过:27关、333个地级单元及30个省直辖地区,全部源子区域/外环/内环保留。');
|
||||
// The detailed-level index records source IDs, names and exterior/hole counts.
|
||||
const detailIndex=JSON.parse(fs.readFileSync('research/detail-map-index.json','utf8'));
|
||||
assert.equal(detailIndex.levels.length,7);let detailPieces=0;
|
||||
for(const entry of detailIndex.levels){const lv=data.levels.find(l=>l.id===entry.id);assert.ok(lv&&lv.kind===entry.kind);assert.equal(lv.regions.length,entry.regions.length);for(const r of lv.regions){const raw=entry.regions.find(v=>v.id===r.id);assert.ok(raw);assert.equal(r.name,raw.name);assert.equal(r.rings.length,raw.rings,r.name+'源外环必须保留');assert.equal((r.holes||[]).length,raw.holes||0,r.name+'源内环必须保留');for(const ring of r.rings.concat(r.holes||[])){assert.ok(ring.length>=3);assert.ok(ring.every(p=>p.every(Number.isFinite)));}if(r.holes&&r.holes.length)assert.ok(r.physics&&r.physics.length);detailPieces++;}}
|
||||
report.detailedLevels=detailIndex.levels.map(l=>({id:l.id,name:l.name,kind:l.kind,pieces:l.regions.length,source:l.source,snapshot:l.snapshot}));report.detailedPieces=detailPieces;report.countyAndPrefectureLimitation='四直辖市县区、香港/澳门分区、台湾县市细分关卡已开放;台湾远海岛屿移位示意,金门/连江不重复列入台湾。';fs.writeFileSync('artifacts/map-audit.json',JSON.stringify(report,null,2));console.log('细分地图核对通过:7个新增关卡、'+detailPieces+'块,源外环/内环及类型标注完整。');
|
||||
|
||||
+11
-3
@@ -1,15 +1,23 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {transform} from 'esbuild';
|
||||
import vm from 'node:vm';
|
||||
import packCountyMaps from './pack-county-maps.cjs';
|
||||
import {transform,build} from 'esbuild';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
fs.rmSync('dist',{force:true,recursive:true});fs.mkdirSync('dist/vendor',{recursive:true});
|
||||
for(const name of ['index.html','style.css','maps.js'])fs.copyFileSync('src/'+name,'dist/'+name);
|
||||
for(const name of ['core.js','game.js','result.js','app.js']){const result=await transform(fs.readFileSync('src/'+name,'utf8'),{target:['chrome61'],minify:true,legalComments:'inline'});fs.writeFileSync('dist/'+name,result.code);}
|
||||
for(const name of ['core.js','atlas.js','game.js','result.js','app.js']){const result=await transform(fs.readFileSync('src/'+name,'utf8'),{target:['chrome61'],minify:true,legalComments:'inline'});fs.writeFileSync('dist/'+name,result.code);}
|
||||
await build({entryPoints:['src/video.js'],outfile:'dist/video.js',bundle:true,format:'iife',target:['es2017','chrome61'],minify:true,legalComments:'inline'});
|
||||
const countyContext={window:{}};vm.runInNewContext(fs.readFileSync('src/county-index.js','utf8'),countyContext);
|
||||
const countyPackage=packCountyMaps(countyContext.window.COUNTY_INDEX,id=>fs.readFileSync('src/county/'+id+'.js','utf8'));
|
||||
fs.writeFileSync('dist/county-index.js','window.COUNTY_INDEX='+JSON.stringify(countyPackage.index)+';\n');
|
||||
fs.mkdirSync('dist/county',{recursive:true});for(const [file,text] of countyPackage.files)fs.writeFileSync('dist/'+file,text);
|
||||
for(const name of ['matter.min.js','decomp.min.js'])fs.copyFileSync('src/vendor/'+name,'dist/vendor/'+name);
|
||||
// Include third-party notices in an allowed local asset, outside game UI.
|
||||
fs.writeFileSync('dist/licenses.json',JSON.stringify({matter:{version:'0.20.0',license:fs.readFileSync('node_modules/matter-js/LICENSE','utf8')},decomp:{version:'0.3.0',license:fs.readFileSync('node_modules/poly-decomp/LICENSE','utf8')},maps:{source:'DataV.GeoAtlas',url:'https://datav.aliyun.com/portal/school/atlas/area_selector',downloaded:'2026-10-16',note:'Public data; geographic outlines simplified for a game. Original provider terms apply.'}},null,2));
|
||||
fs.writeFileSync('dist/licenses.json',JSON.stringify({matter:{version:'0.20.0',license:fs.readFileSync('node_modules/matter-js/LICENSE','utf8')},decomp:{version:'0.3.0',license:fs.readFileSync('node_modules/poly-decomp/LICENSE','utf8')},mp4Muxer:{version:'5.2.2',license:fs.readFileSync('node_modules/mp4-muxer/LICENSE','utf8')},taiwanCounty:{source:'NLSC',snapshot:'COUNTY_MOI_1090820',url:'https://data.gov.tw/dataset/7442',license:'政府資料開放授權條款第1版',licenseURL:'https://data.gov.tw/license',downloaded:'2026-10-17',note:'County/city outlines simplified for gameplay; remote island clusters use a labelled translated inset. Kinmen and Lienchiang belong to Fujian and are not duplicated in the Taiwan level.'},maps:{source:'DataV.GeoAtlas',url:'https://datav.aliyun.com/portal/school/atlas/area_selector',downloaded:'2026-10-16',note:'Public data; geographic outlines simplified for a game. Original provider terms apply.'}},null,2));
|
||||
execFileSync(process.execPath,['scripts/check.mjs'],{stdio:'inherit'});
|
||||
execFileSync(process.execPath,['scripts/audit-maps.cjs'],{stdio:'inherit'});
|
||||
execFileSync(process.execPath,['scripts/audit-county-maps.cjs'],{stdio:'inherit'});
|
||||
fs.mkdirSync('artifacts',{recursive:true});const audit='.codex/skills/minitool-zip-builder/scripts/audit_artifact.py';
|
||||
if(!fs.existsSync(audit))throw Error('缺少用户指定官方 Skill,请先安装 1.8.0。');
|
||||
const dirAudit=execFileSync('python3',[audit,'dist'],{encoding:'utf8'});console.log('官方目录审计:'+dirAudit.trim());
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';import path from 'node:path';import {parse} from 'acorn
|
||||
const issues=[];const root='dist';
|
||||
function walk(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap(d=>d.isDirectory()?walk(path.join(dir,d.name)):[path.join(dir,d.name)]);}
|
||||
const files=walk(root),allowed=new Set(['.html','.css','.js','.json','.png','.jpg','.jpeg','.webp','.gif','.svg','.woff','.woff2']);
|
||||
if(files.length>200)issues.push('ZIP文件数量超过200个上限: '+files.length);
|
||||
for(const f of files){if(!allowed.has(path.extname(f)))issues.push('不允许的文件类型: '+f);if(path.basename(f).startsWith('.'))issues.push('隐藏文件: '+f);const source=fs.readFileSync(f,'utf8');if(f.endsWith('.js')){try{parse(source,{ecmaVersion:2017});}catch(e){issues.push('非 ES2017 语法: '+f+' '+e.message);}const banned=[/\beval\s*\(/,/\bnew\s+Function\s*\(/,/\bfetch\s*\(/,/\bXMLHttpRequest\b/,/\bWebAssembly\b/,/\bnew\s+(?:SharedWorker|Worker|WebSocket|EventSource)\s*\(/,/navigator\.(?:clipboard|geolocation)/,/requestFullscreen\s*\(/,/window\.open\s*\(/];for(const re of banned)if(re.test(source))issues.push('禁用能力 '+re+' '+f);}
|
||||
if(f.endsWith('.html')){for(const m of source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi))if(m[1].trim())issues.push('内联脚本');if(/\son[a-z]+\s*=/i.test(source))issues.push('行内事件');if(/<meta[^>]+Content-Security-Policy|type=[\"']module[\"']/i.test(source))issues.push('自建CSP或模块脚本');if(/<(?:iframe|object|form)\b|javascript:|\sdownload[\s=>]/i.test(source))issues.push('禁止的 HTML 能力');for(const m of source.matchAll(/(?:src|href)="([^"#]+)"/g)){if(!m[1].startsWith('./'))issues.push('非相对本地资源: '+m[1]);else if(!fs.existsSync(path.join(root,m[1])))issues.push('缺失资源: '+m[1]);}}
|
||||
if(f.endsWith('.css')){for(const m of source.matchAll(/url\(([^)]+)\)/g))if(/https?:|\/\//.test(m[1]))issues.push('外部样式资源');const banned=[/[;{]\s*gap\s*:/,/\bclamp\(/,/\bcolor-mix\(/,/\bdvh\b/,/\baspect-ratio\s*:/];for(const re of banned)if(re.test(source))issues.push('无降级的现代 CSS '+re);}}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Download/audit county GIS inputs without changing the released game maps.
|
||||
|
||||
Raw source geometry, reproducible per-city game coordinates and a provenance
|
||||
manifest live in ignored data/. Four cities without county children retain
|
||||
their whole-city outline instead of fabricated county or township polygons.
|
||||
"""
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
from map_geometry import make_level, place_labels
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
BASE = "https://geo.datav.aliyun.com/areas_v3/bound/"
|
||||
EXCEPTIONS = {
|
||||
"441900": "https://www.dg.gov.cn/zjdz/dzgk/xzqh/content/post_2824924.html",
|
||||
"442000": "https://www.zs.gov.cn/zjzs/zsgk/content/post_1669002.html",
|
||||
"460400": "https://www.danzhou.gov.cn/danzhou/index.html",
|
||||
"620200": "https://wap.jyg.gov.cn/zjjyg/",
|
||||
}
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def inspect_features(data, parent, whole=False):
|
||||
if data.get("type") != "FeatureCollection" or not data.get("features"):
|
||||
raise ValueError("Expected a non-empty FeatureCollection")
|
||||
entries, seen = [], set()
|
||||
for feature in data["features"]:
|
||||
prop = feature.get("properties", {})
|
||||
code = str(prop.get("adcode", ""))
|
||||
if not code.isdigit() or code in seen or not prop.get("name"):
|
||||
raise ValueError("Missing/duplicate feature id or name")
|
||||
seen.add(code)
|
||||
if whole:
|
||||
if code != parent:
|
||||
raise ValueError("Whole-city feature does not match city")
|
||||
elif (str(prop.get("parent", {}).get("adcode")) != parent
|
||||
or prop.get("level") != "district"):
|
||||
raise ValueError("County is not an immediate district child")
|
||||
geo = feature.get("geometry", {})
|
||||
if geo.get("type") not in ("Polygon", "MultiPolygon"):
|
||||
raise ValueError("Unexpected non-polygon geometry")
|
||||
polys = geo["coordinates"] if geo["type"] == "MultiPolygon" else [geo["coordinates"]]
|
||||
rings = holes = points = 0
|
||||
for polygon in polys:
|
||||
if not polygon:
|
||||
raise ValueError("Empty polygon")
|
||||
rings += 1
|
||||
holes += len(polygon) - 1
|
||||
for ring in polygon:
|
||||
if len(ring) < 4 or ring[0] != ring[-1]:
|
||||
raise ValueError("Open or degenerate source ring")
|
||||
for point in ring:
|
||||
if (len(point) < 2 or not all(isinstance(v, (int, float))
|
||||
and math.isfinite(v) for v in point[:2])
|
||||
or not -180 <= point[0] <= 180 or not -90 <= point[1] <= 90):
|
||||
raise ValueError("Invalid geographic coordinate")
|
||||
if abs(sum(a[0]*b[1]-b[0]*a[1] for a, b in zip(ring, ring[1:]))) < 1e-15:
|
||||
raise ValueError("Zero-area source ring")
|
||||
points += len(ring)
|
||||
entries.append({"id": code, "name": prop["name"], "rings": rings,
|
||||
"holes": holes, "points": points})
|
||||
return entries
|
||||
|
||||
|
||||
def get_source(code, folder, whole=False):
|
||||
suffix = ".json" if whole else "_full.json"
|
||||
url = BASE + code + suffix
|
||||
path = folder / (code + ".json")
|
||||
if path.exists():
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw)
|
||||
regions = inspect_features(data, code, whole)
|
||||
return raw, data, regions, url
|
||||
for attempt in range(3):
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "ShanhePuzzle-GIS/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=35) as response:
|
||||
raw = response.read()
|
||||
data = json.loads(raw)
|
||||
regions = inspect_features(data, code, whole)
|
||||
tmp = path.with_suffix(".partial")
|
||||
tmp.write_bytes(raw)
|
||||
tmp.replace(path)
|
||||
return raw, data, regions, url
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404 or attempt == 2:
|
||||
raise
|
||||
except Exception:
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(.5 * (attempt + 1))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--snapshot", required=True, help="Local YYYY-MM-DD snapshot label")
|
||||
parser.add_argument("--only", help="Optional comma-separated city ids for a small probe")
|
||||
args = parser.parse_args()
|
||||
datetime.date.fromisoformat(args.snapshot)
|
||||
index = read_json(ROOT / "research/prefecture-map-index.json")
|
||||
cities = [dict(region, province=province["id"], provinceName=province["name"])
|
||||
for province in index["levels"] for region in province["regions"]
|
||||
if region["unitType"] == "prefecture"]
|
||||
if args.only:
|
||||
selected = set(args.only.split(","))
|
||||
cities = [city for city in cities if city["id"] in selected]
|
||||
if len(cities) != len(selected):
|
||||
raise ValueError("Unknown city id in --only")
|
||||
dest = ROOT / "data/city-county-gis" / args.snapshot
|
||||
for name in ["raw", "unsplit", "levels"]:
|
||||
(dest / name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def fetch(city):
|
||||
code = city["id"]
|
||||
entry = {"id": code, "name": city["name"], "province": city["province"],
|
||||
"provinceName": city["provinceName"]}
|
||||
try:
|
||||
raw, data, regions, url = get_source(code, dest / "raw")
|
||||
entry.update(status="available", path="raw/"+code+".json", source=url,
|
||||
sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw), regions=regions)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 404 or code not in EXCEPTIONS:
|
||||
entry.update(status="failed", reason=str(error))
|
||||
return entry
|
||||
try:
|
||||
raw, data, regions, url = get_source(code, dest / "unsplit", whole=True)
|
||||
prop = data["features"][0]["properties"]
|
||||
if len(regions) != 1 or prop.get("childrenNum") != 0:
|
||||
raise ValueError("Cannot confirm the no-county-child exception")
|
||||
entry.update(status="no_county_children", path="unsplit/"+code+".json",
|
||||
source=url, unavailableChildSource=BASE+code+"_full.json",
|
||||
sourceChildrenNum=0, reference=EXCEPTIONS[code],
|
||||
sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw), regions=regions,
|
||||
note="Whole-city outline only; township boundaries are not supplied by this source.")
|
||||
except Exception as fallback_error:
|
||||
entry.update(status="failed", reason=str(fallback_error))
|
||||
except Exception as error:
|
||||
entry.update(status="failed", reason=str(error))
|
||||
return entry
|
||||
|
||||
entries = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
||||
futures = [pool.submit(fetch, city) for city in cities]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
entries.append(future.result())
|
||||
if len(entries) % 25 == 0 or len(entries) == len(cities):
|
||||
print("GIS fetched:", len(entries), "/", len(cities), flush=True)
|
||||
entries.sort(key=lambda entry: entry["id"])
|
||||
failed = [entry for entry in entries if entry["status"] == "failed"]
|
||||
for entry in entries:
|
||||
if entry["status"] != "available":
|
||||
continue
|
||||
data = read_json(dest / entry["path"])
|
||||
level = make_level(data["features"], int(entry["id"]), entry["name"], int(entry["province"]))
|
||||
if not level:
|
||||
raise ValueError("No playable subdivision for " + entry["id"])
|
||||
level.update(kind="county", parent=entry["province"], playLabel="县区拼图")
|
||||
level.pop("projection", None)
|
||||
place_labels(level)
|
||||
source_by_id = {region["id"]: region for region in entry["regions"]}
|
||||
assert {region["id"] for region in level["regions"]} == set(source_by_id)
|
||||
for region in level["regions"]:
|
||||
expected = source_by_id[region["id"]]
|
||||
assert region["name"] == expected["name"]
|
||||
assert len(region["rings"]) == expected["rings"]
|
||||
assert len(region.get("holes", [])) == expected["holes"]
|
||||
for ring in region["rings"] + region.get("holes", []):
|
||||
assert len(ring) >= 3 and all(all(math.isfinite(v) for v in p) for p in ring)
|
||||
assert abs(sum(ring[i][0]*ring[(i+1)%len(ring)][1]
|
||||
-ring[(i+1)%len(ring)][0]*ring[i][1]
|
||||
for i in range(len(ring)))) > 1e-10
|
||||
encoded = json.dumps(level, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
path = dest / "levels" / (entry["id"] + ".json")
|
||||
path.write_bytes(encoded)
|
||||
entry["preparedPath"] = "levels/" + path.name
|
||||
entry["preparedSha256"] = hashlib.sha256(encoded).hexdigest()
|
||||
available = [entry for entry in entries if entry["status"] == "available"]
|
||||
manifest = {"source": "DataV.GeoAtlas", "snapshotDate": args.snapshot,
|
||||
"scope": "Existing game's prefectural areas; provider snapshot, not a guarantee of current administrative boundaries.",
|
||||
"rawCoordinates": "Source longitude/latitude values preserved; datum not declared by the source, no WGS84/GCJ02 assumption.",
|
||||
"preparedCoordinates": "Simplified game-plane coordinates, longest extent 1000; not geographic GIS coordinates.",
|
||||
"releaseMapsChanged": False, "requested": len(cities), "available": len(available),
|
||||
"countyPieces": sum(len(entry["regions"]) for entry in available),
|
||||
"noCountyChildren": sum(entry["status"] == "no_county_children" for entry in entries),
|
||||
"failed": len(failed), "cities": entries}
|
||||
(dest / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+"\n")
|
||||
(dest / "README.md").write_text("# City county GIS material snapshot\n\n"
|
||||
"raw/: original per-city GeoJSON county outlines; longitude/latitude values are preserved.\n"
|
||||
"levels/: simplified per-city game coordinates, with every source outer ring and hole retained.\n"
|
||||
"unsplit/: whole-city outlines for four cities that report no county children.\n"
|
||||
"manifest.json: source URLs, county names/codes, raw/prepared checksums, coverage and exceptions.\n\n"
|
||||
"Prepared files still require collision decomposition, topology/playability and mobile performance validation before game integration. "
|
||||
"They are materials, not an uploadable mini-tool ZIP. No released game map is modified.\n")
|
||||
print(json.dumps({k: manifest[k] for k in ["requested", "available", "countyPieces", "noCountyChildren", "failed"]}), flush=True)
|
||||
if failed:
|
||||
print(json.dumps(failed, ensure_ascii=False), flush=True)
|
||||
raise SystemExit(1)
|
||||
artifact = ROOT / "artifacts" / ("city-county-gis-"+args.snapshot.replace("-", "")+".zip")
|
||||
artifact.parent.mkdir(exist_ok=True)
|
||||
paths = [dest / "manifest.json", dest / "README.md"]
|
||||
for entry in entries:
|
||||
paths.append(dest / entry["path"])
|
||||
if entry.get("preparedPath"):
|
||||
paths.append(dest / entry["preparedPath"])
|
||||
with zipfile.ZipFile(artifact, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as bundle:
|
||||
for path in sorted(paths):
|
||||
bundle.write(path, path.relative_to(dest).as_posix())
|
||||
print("GIS material ZIP:", artifact, "|", artifact.stat().st_size, "bytes", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Build-time geometry simplification shared by prefecture and detailed maps."""
|
||||
import math
|
||||
def area(r):return abs(sum(r[i][0]*r[(i+1)%len(r)][1]-r[(i+1)%len(r)][0]*r[i][1] for i in range(len(r)))/2)
|
||||
def dist(p,a,b):
|
||||
dx,dy=b[0]-a[0],b[1]-a[1];n=dx*dx+dy*dy
|
||||
t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/n)) if n else 0
|
||||
return (p[0]-a[0]-t*dx)**2+(p[1]-a[1]-t*dy)**2
|
||||
def dp(p,eps):
|
||||
if len(p)<3:return p
|
||||
v,i=max((dist(p[k],p[0],p[-1]),k) for k in range(1,len(p)-1))
|
||||
if v<=eps*eps:return [p[0],p[-1]]
|
||||
return dp(p[:i+1],eps)[:-1]+dp(p[i:],eps)
|
||||
def project(p):return [p[0]*.82,-math.log(math.tan(math.pi/4+math.radians(p[1])/2))*180/math.pi*.82]
|
||||
def make_level(features,code,name,province):
|
||||
fs=[f for f in features if f.get('properties',{}).get('name') and f['properties'].get('adcode')]
|
||||
if len(fs)<2:return None
|
||||
reg=[];boundary=[]
|
||||
for f in fs:
|
||||
prop=f['properties'];g=f['geometry'];polys=g['coordinates'] if g['type']=='MultiPolygon' else [g['coordinates']]
|
||||
rings=[project_ring(p[0]) for p in polys if len(p[0])>=4]
|
||||
holes=[project_ring(hole) for poly in polys for hole in poly[1:] if len(hole)>=4]
|
||||
rings.sort(key=area,reverse=True)
|
||||
if not rings:continue
|
||||
main=rings[0];boundary.append({(round(x,3),round(y,3)) for r in rings+holes for x,y in r})
|
||||
# Keep island geometry, simplify each independently; no invented boundaries.
|
||||
span=max(max(p[0] for p in main)-min(p[0] for p in main),max(p[1] for p in main)-min(p[1] for p in main))
|
||||
simple=[]
|
||||
for r in rings:
|
||||
localspan=max(max(p[0] for p in r)-min(p[0] for p in r),max(p[1] for p in r)-min(p[1] for p in r))
|
||||
eps=max(min(span*.005,localspan*.01),.00000001);q=dp(r,eps)
|
||||
while len(q)>90:eps*=1.4;q=dp(r,eps)
|
||||
if len(q)<4:q=r
|
||||
simple.append(q[:-1] if q[0]==q[-1] else q)
|
||||
cx=sum(x for x,y in main[:-1])/max(1,len(main)-1);cy=sum(y for x,y in main[:-1])/max(1,len(main)-1)
|
||||
entry={'id':str(prop['adcode']),'name':prop['name'],'rings':simple,'center':[cx,cy]}
|
||||
if holes:
|
||||
entry['holes']=[]
|
||||
for hole in holes:
|
||||
eps=span*.001;q=dp(hole,eps)
|
||||
while len(q)>90:eps*=1.4;q=dp(hole,eps)
|
||||
if len(q)<4:q=hole
|
||||
entry['holes'].append(q[:-1] if q[0]==q[-1] else q)
|
||||
reg.append(entry)
|
||||
if len(reg)<2:return None
|
||||
edges=[]
|
||||
for i in range(len(reg)):
|
||||
for j in range(i):
|
||||
if len(boundary[i]&boundary[j])>=2:edges.append([j,i,0])
|
||||
# Join disconnected island components with explicit game-only sea routes.
|
||||
def components():
|
||||
groups=[{i} for i in range(len(reg))]
|
||||
for a,b,_ in edges:
|
||||
ga=next(g for g in groups if a in g);gb=next(g for g in groups if b in g)
|
||||
if ga is not gb:ga.update(gb);groups.remove(gb)
|
||||
return groups
|
||||
while len(components())>1:
|
||||
gs=components();candidates=[]
|
||||
for a in gs[0]:
|
||||
for g in gs[1:]:
|
||||
for b in g:
|
||||
pa,pb=reg[a]['center'],reg[b]['center'];candidates.append(((pa[0]-pb[0])**2+(pa[1]-pb[1])**2,a,b))
|
||||
_,a,b=min(candidates);edges.append([a,b,1])
|
||||
allp=[p for r in reg for ring in r['rings'] for p in ring]
|
||||
minx,miny=min(p[0] for p in allp),min(p[1] for p in allp);maxx,maxy=max(p[0] for p in allp),max(p[1] for p in allp)
|
||||
span=max(maxx-minx,maxy-miny);scale=1000/span
|
||||
for r in reg:
|
||||
r['center']=[round((r['center'][0]-minx)*scale,2),round((r['center'][1]-miny)*scale,2)]
|
||||
r['rings']=[[[round((x-minx)*scale,4),round((y-miny)*scale,4)] for x,y in ring] for ring in r['rings']]
|
||||
if r.get('holes'):r['holes']=[[[round((x-minx)*scale,4),round((y-miny)*scale,4)] for x,y in ring] for ring in r['holes']]
|
||||
return {'id':str(code),'name':name,'province':str(province),'width':round((maxx-minx)*scale,2),'height':round((maxy-miny)*scale,2),'regions':reg,'edges':edges,'projection':[minx,miny,scale]}
|
||||
def project_ring(r):return [project(p) for p in r]
|
||||
|
||||
def inside(p,ring):
|
||||
x,y=p;hit=False
|
||||
for i,a in enumerate(ring):
|
||||
b=ring[(i+1)%len(ring)]
|
||||
if (a[1]>y)!=(b[1]>y) and x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0]:hit=not hit
|
||||
return hit
|
||||
|
||||
def place_labels(lv):
|
||||
for r in lv['regions']:
|
||||
ring=r['rings'][0];minx,maxx=min(p[0] for p in ring),max(p[0] for p in ring);miny,maxy=min(p[1] for p in ring),max(p[1] for p in ring)
|
||||
candidates=[[minx+(maxx-minx)*i/14,miny+(maxy-miny)*j/14] for i in range(1,14) for j in range(1,14)]
|
||||
candidates=[p for p in candidates if inside(p,ring) and not any(inside(p,hole) for hole in r.get('holes',[]))]
|
||||
if candidates:r['label']=max(candidates,key=lambda p:min(dist(p,ring[k],ring[(k+1)%len(ring)]) for k in range(len(ring))))
|
||||
else:r['label']=r['center']
|
||||
r['label']=[round(v,3) for v in r['label']]
|
||||
|
||||
|
||||
def hainan_offshore_inset(lv):
|
||||
"""Gameplay inset: preserve every Sansha ring, uniformly scale and translate.
|
||||
|
||||
The national map and raw coordinates are untouched. Only this provincial
|
||||
puzzle uses a clearly labelled inset so the main island remains playable.
|
||||
"""
|
||||
if lv['id']!='460000':return
|
||||
offshore=next(r for r in lv['regions'] if r['id']=='460300')
|
||||
for r in lv['regions']:
|
||||
r['labelName']=r['name'].split('黎族')[0] if '黎族' in r['name'] else r['name'].removesuffix('市').removesuffix('县')
|
||||
mainland=[p for r in lv['regions'] if r is not offshore for ring in r['rings'] for p in ring]
|
||||
sea=[p for ring in offshore['rings'] for p in ring]
|
||||
minx,maxx=min(p[0] for p in mainland),max(p[0] for p in mainland)
|
||||
miny,maxy=min(p[1] for p in mainland),max(p[1] for p in mainland)
|
||||
sx,sy=min(p[0] for p in sea),min(p[1] for p in sea)
|
||||
sw,sh=max(p[0] for p in sea)-sx,max(p[1] for p in sea)-sy
|
||||
width,height=maxx-minx,maxy-miny
|
||||
factor=min(width*.38/sw,height*.55/sh)
|
||||
tx,ty=maxx+width*.10,maxy-height*.53
|
||||
move=lambda p:[tx+(p[0]-sx)*factor,ty+(p[1]-sy)*factor]
|
||||
offshore['rings']=[[move(p) for p in ring] for ring in offshore['rings']]
|
||||
if offshore.get('holes'):offshore['holes']=[[move(p) for p in ring] for ring in offshore['holes']]
|
||||
offshore['center']=move(offshore['center'])
|
||||
allp=[p for r in lv['regions'] for ring in r['rings'] for p in ring]
|
||||
bx,by=min(p[0] for p in allp),min(p[1] for p in allp)
|
||||
bw,bh=max(p[0] for p in allp)-bx,max(p[1] for p in allp)-by
|
||||
scale=1000/max(bw,bh)
|
||||
normalize=lambda p:[round((p[0]-bx)*scale,4),round((p[1]-by)*scale,4)]
|
||||
for r in lv['regions']:
|
||||
r['rings']=[[normalize(p) for p in ring] for ring in r['rings']]
|
||||
if r.get('holes'):r['holes']=[[normalize(p) for p in ring] for ring in r['holes']]
|
||||
r['center']=normalize(r['center'])
|
||||
lv['width'],lv['height']=round(bw*scale,2),round(bh*scale,2)
|
||||
# Reserve space around the islands, for a visible frame and explanatory label.
|
||||
a,b=normalize([tx-width*.025,ty-height*.025]),normalize([tx+sw*factor+width*.025,ty+sh*factor+height*.025])
|
||||
lv['mapInsets']=[{'id':'460300','label':'三沙诸岛 · 缩放移位示意','x':a[0],'y':a[1],'width':b[0]-a[0],'height':b[1]-a[1]}]
|
||||
lv['scopeNote']='含15个省直辖地区;三沙诸岛缩放移位示意。'
|
||||
lv['unitCaption']='地级及省直辖 · 三沙移位示意'
|
||||
lv['projectionNote']='海南省域游戏关卡使用三沙诸岛缩放移位示意,全国地图与源素材坐标保持原状。'
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
// Keep editable/auditable maps separate in source, but ship at most two
|
||||
// same-province stages per classic script to meet the 200-file ZIP limit.
|
||||
module.exports=function packCountyMaps(index,readStage){
|
||||
const levels=index.levels.map(meta=>({...meta})),provinces=new Map(),files=new Map(),seen=new Set();
|
||||
for(const meta of levels){if(!/^\d{6}$/.test(meta.id)||!/^\d{6}$/.test(meta.province)||seen.has(meta.id))throw Error('Invalid county catalog');seen.add(meta.id);if(!provinces.has(meta.province))provinces.set(meta.province,[]);provinces.get(meta.province).push(meta);}
|
||||
for(const entries of provinces.values()){entries.sort((a,b)=>a.id.localeCompare(b.id));for(let i=0;i<entries.length;i+=2){const group=entries.slice(i,i+2),file='county/'+group[0].id+'.js';for(const meta of group)meta.path='./'+file;files.set(file,group.map(meta=>readStage(meta.id)).join('\n'));}}
|
||||
return {index:{...index,levels},files};
|
||||
};
|
||||
+5
-3
@@ -1,10 +1,12 @@
|
||||
import pathlib,zipfile,json,hashlib
|
||||
root=pathlib.Path('dist');out=pathlib.Path('artifacts/shanhe-puzzle-xhs.zip')
|
||||
files=sorted(p for p in root.rglob('*') if p.is_file())
|
||||
if len(files)>200:raise SystemExit(f'ZIP文件数量超过200个上限: {len(files)}')
|
||||
with zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED,compresslevel=9) as z:
|
||||
for p in sorted(root.rglob('*')):
|
||||
if p.is_file():z.write(p,p.relative_to(root).as_posix())
|
||||
for p in files:z.write(p,p.relative_to(root).as_posix())
|
||||
with zipfile.ZipFile(out) as z:
|
||||
assert 'index.html' in z.namelist()
|
||||
assert z.testzip() is None
|
||||
assert len(z.infolist())<=200
|
||||
print('离线 ZIP:',out.resolve(),' | ',round(out.stat().st_size/1024),'KB')
|
||||
pathlib.Path('artifacts/package-report.json').write_text(json.dumps({'file':str(out),'bytes':out.stat().st_size,'sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'archiveIntegrity':'passed','rootEntry':'index.html'},indent=2))
|
||||
pathlib.Path('artifacts/package-report.json').write_text(json.dumps({'file':str(out),'bytes':out.stat().st_size,'fileCount':len(files),'fileLimit':200,'sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'archiveIntegrity':'passed','rootEntry':'index.html'},indent=2))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Prepare independently loaded city/county game assets from a GIS snapshot.
|
||||
|
||||
Requires Shapely only for this preparation step; npm run build uses the saved
|
||||
src/county assets and source index and does not require Python GIS packages.
|
||||
"""
|
||||
import argparse, copy, hashlib, json, math, pathlib
|
||||
from shapely import make_valid
|
||||
from shapely.geometry import Polygon, MultiPolygon
|
||||
from map_geometry import project
|
||||
|
||||
ROOT=pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
def polygons(geometry):
|
||||
if geometry.geom_type=='Polygon':return [geometry]
|
||||
if hasattr(geometry,'geoms'):return [p for item in geometry.geoms for p in polygons(item)]
|
||||
return []
|
||||
|
||||
def rounded_ring(ring,digits):
|
||||
points=[]
|
||||
for p in list(ring.coords)[:-1]:
|
||||
q=[round(p[0],digits),round(p[1],digits)]
|
||||
q=[int(v) if float(v).is_integer() else v for v in q]
|
||||
if not points or q!=points[-1]:points.append(q)
|
||||
if len(points)>1 and points[0]==points[-1]:points.pop()
|
||||
return points
|
||||
|
||||
def compact_polygon(poly):
|
||||
x1,y1,x2,y2=poly.bounds;span=max(x2-x1,y2-y1);eps=span*.003
|
||||
simple=poly.simplify(eps,preserve_topology=True)
|
||||
for _ in range(18):
|
||||
if len(simple.exterior.coords)<=81:break
|
||||
candidate=poly.simplify(eps*1.3,preserve_topology=True)
|
||||
if candidate.area<poly.area*.97 or candidate.area>poly.area*1.03:break
|
||||
simple=candidate;eps*=1.3
|
||||
for digits in [1,2,3,4,6,8]:
|
||||
outer=rounded_ring(simple.exterior,digits);holes=[rounded_ring(h,digits) for h in simple.interiors]
|
||||
if len(outer)<3 or any(len(h)<3 for h in holes):continue
|
||||
candidate=Polygon(outer,holes)
|
||||
if candidate.is_valid and candidate.area>0 and abs(candidate.area-simple.area)/simple.area<=.01:
|
||||
return outer,holes,digits
|
||||
raise ValueError('Cannot quantize a polygon without losing its topology')
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--snapshot',default='2026-10-18');args=parser.parse_args()
|
||||
snapshot=ROOT/'data/city-county-gis'/args.snapshot;manifest=json.loads((snapshot/'manifest.json').read_text())
|
||||
dest=ROOT/'src/county';dest.mkdir(exist_ok=True);index={'source':manifest['source'],'snapshot':args.snapshot,'levels':[],'unavailable':[]};catalog=[];total=0;repairs=0
|
||||
for entry in manifest['cities']:
|
||||
if entry['status']!='available':
|
||||
index['unavailable'].append({'id':entry['id'],'name':entry['name'],'province':entry['province'],'reason':'no_county_children'})
|
||||
continue
|
||||
old=json.loads((snapshot/entry['preparedPath']).read_text());raw=json.loads((snapshot/entry['path']).read_text())
|
||||
all_points=[project(p) for f in raw['features'] for poly in (f['geometry']['coordinates'] if f['geometry']['type']=='MultiPolygon' else [f['geometry']['coordinates']]) for p in poly[0]]
|
||||
minx,miny=min(p[0] for p in all_points),min(p[1] for p in all_points);span=max(max(p[0] for p in all_points)-minx,max(p[1] for p in all_points)-miny);scale=1000/span
|
||||
def local(p):
|
||||
q=project(p);return [(q[0]-minx)*scale,(q[1]-miny)*scale]
|
||||
level={k:copy.deepcopy(old[k]) for k in ['id','name','province','width','height','edges']}
|
||||
level.update(kind='county',parent=entry['province'],provinceName=entry['provinceName'],playLabel='县区拼图',unitCaption='市辖区 · 县 · 县级市',scopeNote='左右调整待落块,松手落下,拼完整片'+entry['name']+'。',regions=[])
|
||||
proof=[]
|
||||
for feature in raw['features']:
|
||||
prop=feature['properties'];source_polys=feature['geometry']['coordinates'] if feature['geometry']['type']=='MultiPolygon' else [feature['geometry']['coordinates']]
|
||||
output=[];fixed=0
|
||||
for source_poly in source_polys:
|
||||
poly=Polygon([local(p) for p in source_poly[0]],[[local(p) for p in h] for h in source_poly[1:]])
|
||||
if not poly.is_valid:poly=make_valid(poly);fixed+=1
|
||||
pieces=polygons(poly)
|
||||
if not pieces:raise ValueError('No polygon after source repair')
|
||||
output.extend(pieces)
|
||||
output.sort(key=lambda p:p.area,reverse=True);region={'id':str(prop['adcode']),'name':prop['name'],'rings':[]};holes=[]
|
||||
for poly in output:
|
||||
outer,inner,digits=compact_polygon(poly);region['rings'].append(outer);holes.extend(inner)
|
||||
if holes:region['holes']=holes
|
||||
center=MultiPolygon(output).centroid;label=output[0].representative_point()
|
||||
region['center']=[round(center.x,1),round(center.y,1)];region['label']=[round(label.x,1),round(label.y,1)]
|
||||
level['regions'].append(region);repairs+=fixed
|
||||
proof.append({'id':region['id'],'name':region['name'],'sourceRings':len(source_polys),'sourceHoles':sum(len(p)-1 for p in source_polys),'rings':len(region['rings']),'holes':len(holes),'repairedSourcePolygons':fixed})
|
||||
assert [r['id'] for r in level['regions']]==[r['id'] for r in old['regions']]
|
||||
payload=json.dumps(level,ensure_ascii=False,separators=(',',':'))
|
||||
(dest/(level['id']+'.js')).write_text('window.PuzzleAtlas.register('+payload+');\n')
|
||||
index['levels'].append({'id':level['id'],'name':level['name'],'province':level['province'],'provinceName':entry['provinceName'],'sha256':entry['sha256'],'regions':proof})
|
||||
catalog.append({'id':level['id'],'name':level['name'],'province':level['province'],'provinceName':entry['provinceName'],'kind':'county','playLabel':'县区拼图','count':len(level['regions']),'path':'./county/'+level['id']+'.js'})
|
||||
total+=len(payload.encode())
|
||||
(ROOT/'src/county-index.js').write_text('window.COUNTY_INDEX='+json.dumps({'levels':catalog,'unavailable':index['unavailable']},ensure_ascii=False,separators=(',',':'))+';\n')
|
||||
(ROOT/'research/county-map-index.json').write_text(json.dumps(index,ensure_ascii=False,separators=(',',':'))+'\n')
|
||||
print('County assets:',len(catalog),'levels;',sum(v['count'] for v in catalog),'pieces;',total,'bytes; source polygon repairs:',repairs)
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Add a small, explicitly scoped set of detailed puzzles from local map inputs.
|
||||
|
||||
Run after prepare-maps.py. Raw downloads stay in ignored data/, while the
|
||||
compact source index is required by the build-time geographic audit.
|
||||
"""
|
||||
import copy, hashlib, json, pathlib, subprocess
|
||||
from map_geometry import make_level, place_labels
|
||||
ROOT=pathlib.Path(__file__).resolve().parent.parent
|
||||
source=ROOT/'src/maps.js'
|
||||
data=json.loads(source.read_text().removeprefix('window.MAP_DATA=').strip().removesuffix(';'))
|
||||
# Preserve the national and 27 provincial levels, including province-administered peers.
|
||||
data['levels']=[lv for lv in data['levels'] if lv.get('kind') not in ('county','partition','county-city')]
|
||||
for lv in data['levels']:
|
||||
lv['kind']='province' if lv['id']=='100000' else 'prefecture'
|
||||
index={'downloadDate':'2026-10-17','levels':[]}
|
||||
expected={'110000':('北京市',16),'120000':('天津市',16),'310000':('上海市',16),'500000':('重庆市',38),'810000':('香港特别行政区',18),'820000':('澳门特别行政区',8)}
|
||||
for code,(name,count) in expected.items():
|
||||
raw=ROOT/'data/provinces-raw'/(code+'.json')
|
||||
features=json.loads(raw.read_text())['features']
|
||||
features=[f for f in features if f['properties'].get('level')=='district']
|
||||
assert len(features)==count,(code,len(features))
|
||||
lv=make_level(features,code,name,code)
|
||||
assert lv and len(lv['regions'])==count
|
||||
lv.pop('projection',None);lv['kind']='partition' if code in ('810000','820000') else 'county'
|
||||
lv['playLabel']='分区拼图' if lv['kind']=='partition' else '县区拼图'
|
||||
lv['unitCaption']='按素材分区 · 堂区及路凼填海区' if code=='820000' else '18个分区' if code=='810000' else '区 · 县 · 自治县' if code=='500000' else '市辖区'
|
||||
lv['source']='DataV.GeoAtlas';place_labels(lv);data['levels'].append(lv)
|
||||
index['levels'].append({'id':code,'name':name,'kind':lv['kind'],'source':'DataV.GeoAtlas','sha256':hashlib.sha256(raw.read_bytes()).hexdigest(),'regions':[{'id':str(f['properties']['adcode']),'name':f['properties']['name'],'rings':len(f['geometry']['coordinates']) if f['geometry']['type']=='MultiPolygon' else 1,'holes':sum(len(poly)-1 for poly in f['geometry']['coordinates']) if f['geometry']['type']=='MultiPolygon' else len(f['geometry']['coordinates'])-1} for f in features]})
|
||||
# An explicitly attributed county/city source is separate from the national map.
|
||||
taiwan=ROOT/'data/taiwan-raw/counties.geojson'
|
||||
if not taiwan.exists():raise SystemExit('缺少台湾县市构建输入,请先运行 scripts/prepare-taiwan.py')
|
||||
features=json.loads(taiwan.read_text())['features']
|
||||
assert len(features)==20
|
||||
play_features=copy.deepcopy(features)
|
||||
# Keep every source exterior ring. Two remote island clusters are translated
|
||||
# into a labelled inset area so one county does not span thousands of km.
|
||||
insets=0
|
||||
for f in play_features:
|
||||
for poly in f['geometry']['coordinates']:
|
||||
ring=poly[0];cx=sum(p[0] for p in ring)/len(ring);cy=sum(p[1] for p in ring)/len(ring)
|
||||
if cy<21 or cx<118:
|
||||
target=(118.95,21.7) if cy<15 else (119.35,21.7)
|
||||
dx,dy=target[0]-cx,target[1]-cy
|
||||
for p in ring:p[0]+=dx;p[1]+=dy
|
||||
insets+=1
|
||||
assert insets>0
|
||||
lv=make_level(play_features,'710000','台湾省','710000');lv.pop('projection',None)
|
||||
lv.update(kind='county-city',playLabel='县市拼图',unitCaption='县市 · 远海岛屿移位示意',source='NLSC',scopeNote='县市关卡包括台湾岛及澎湖;远海岛屿采用移位示意。金门、连江属福建,不重复列入。')
|
||||
place_labels(lv);data['levels'].append(lv)
|
||||
index['levels'].append({'id':'710000','name':'台湾省','kind':'county-city','source':'NLSC','snapshot':'COUNTY_MOI_1090820','translatedOffshoreRings':insets,'sourceURL':'https://data.gov.tw/dataset/7442','license':'政府资料开放授权条款第1版','sha256':hashlib.sha256(taiwan.read_bytes()).hexdigest(),'regions':[{'id':str(f['properties']['adcode']),'name':f['properties']['name'],'rings':len(f['geometry']['coordinates']),'holes':sum(len(poly)-1 for poly in f['geometry']['coordinates'])} for f in features]})
|
||||
assert len(data['levels'])==35
|
||||
source.write_text('window.MAP_DATA='+json.dumps(data,ensure_ascii=False,separators=(',',':'))+';\n')
|
||||
subprocess.run(['node',str(ROOT/'scripts/triangulate-holes.mjs')],check=True,cwd=ROOT)
|
||||
(ROOT/'research/detail-map-index.json').write_text(json.dumps(index,ensure_ascii=False,indent=2)+'\n')
|
||||
print('Detailed levels:',[(lv['name'],lv['playLabel'],len(lv['regions'])) for lv in data['levels'] if lv['kind'] not in ('province','prefecture')])
|
||||
+34
-80
@@ -1,65 +1,7 @@
|
||||
import json, math, pathlib, collections
|
||||
import json, math, pathlib, collections, subprocess, hashlib
|
||||
ROOT=pathlib.Path(__file__).resolve().parent.parent
|
||||
AREAS=json.load(open(ROOT/'research/areas.json'));LOOKUP={a['adcode']:a for a in AREAS}
|
||||
def area(r):return abs(sum(r[i][0]*r[(i+1)%len(r)][1]-r[(i+1)%len(r)][0]*r[i][1] for i in range(len(r)))/2)
|
||||
def dist(p,a,b):
|
||||
dx,dy=b[0]-a[0],b[1]-a[1];n=dx*dx+dy*dy
|
||||
t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/n)) if n else 0
|
||||
return (p[0]-a[0]-t*dx)**2+(p[1]-a[1]-t*dy)**2
|
||||
def dp(p,eps):
|
||||
if len(p)<3:return p
|
||||
v,i=max((dist(p[k],p[0],p[-1]),k) for k in range(1,len(p)-1))
|
||||
if v<=eps*eps:return [p[0],p[-1]]
|
||||
return dp(p[:i+1],eps)[:-1]+dp(p[i:],eps)
|
||||
def project(p):return [p[0]*.82,-math.log(math.tan(math.pi/4+math.radians(p[1])/2))*180/math.pi*.82]
|
||||
def make_level(features,code,name,province):
|
||||
fs=[f for f in features if f.get('properties',{}).get('name') and f['properties'].get('adcode')]
|
||||
if len(fs)<2:return None
|
||||
reg=[];boundary=[]
|
||||
for f in fs:
|
||||
prop=f['properties'];g=f['geometry'];polys=g['coordinates'] if g['type']=='MultiPolygon' else [g['coordinates']]
|
||||
rings=[project_ring(p[0]) for p in polys if len(p[0])>=4]
|
||||
rings.sort(key=area,reverse=True)
|
||||
if not rings:continue
|
||||
main=rings[0];boundary.append({(round(x,3),round(y,3)) for r in rings for x,y in r})
|
||||
# Keep island geometry, simplify each independently; no invented boundaries.
|
||||
span=max(max(p[0] for p in main)-min(p[0] for p in main),max(p[1] for p in main)-min(p[1] for p in main))
|
||||
simple=[]
|
||||
for r in rings:
|
||||
localspan=max(max(p[0] for p in r)-min(p[0] for p in r),max(p[1] for p in r)-min(p[1] for p in r))
|
||||
eps=max(min(span*.005,localspan*.01),.00000001);q=dp(r,eps)
|
||||
while len(q)>90:eps*=1.4;q=dp(r,eps)
|
||||
if len(q)<4:q=r
|
||||
simple.append(q[:-1] if q[0]==q[-1] else q)
|
||||
cx=sum(x for x,y in main[:-1])/max(1,len(main)-1);cy=sum(y for x,y in main[:-1])/max(1,len(main)-1)
|
||||
reg.append({'id':str(prop['adcode']),'name':prop['name'],'rings':simple,'center':[cx,cy]})
|
||||
if len(reg)<2:return None
|
||||
edges=[]
|
||||
for i in range(len(reg)):
|
||||
for j in range(i):
|
||||
if len(boundary[i]&boundary[j])>=2:edges.append([j,i,0])
|
||||
# Join disconnected island components with explicit game-only sea routes.
|
||||
def components():
|
||||
groups=[{i} for i in range(len(reg))]
|
||||
for a,b,_ in edges:
|
||||
ga=next(g for g in groups if a in g);gb=next(g for g in groups if b in g)
|
||||
if ga is not gb:ga.update(gb);groups.remove(gb)
|
||||
return groups
|
||||
while len(components())>1:
|
||||
gs=components();candidates=[]
|
||||
for a in gs[0]:
|
||||
for g in gs[1:]:
|
||||
for b in g:
|
||||
pa,pb=reg[a]['center'],reg[b]['center'];candidates.append(((pa[0]-pb[0])**2+(pa[1]-pb[1])**2,a,b))
|
||||
_,a,b=min(candidates);edges.append([a,b,1])
|
||||
allp=[p for r in reg for ring in r['rings'] for p in ring]
|
||||
minx,miny=min(p[0] for p in allp),min(p[1] for p in allp);maxx,maxy=max(p[0] for p in allp),max(p[1] for p in allp)
|
||||
span=max(maxx-minx,maxy-miny);scale=1000/span
|
||||
for r in reg:
|
||||
r['center']=[round((r['center'][0]-minx)*scale,2),round((r['center'][1]-miny)*scale,2)]
|
||||
r['rings']=[[[round((x-minx)*scale,4),round((y-miny)*scale,4)] for x,y in ring] for ring in r['rings']]
|
||||
return {'id':str(code),'name':name,'province':str(province),'width':round((maxx-minx)*scale,2),'height':round((maxy-miny)*scale,2),'regions':reg,'edges':edges,'projection':[minx,miny,scale]}
|
||||
def project_ring(r):return [project(p) for p in r]
|
||||
from map_geometry import make_level, project, place_labels, hainan_offshore_inset
|
||||
levels=[]
|
||||
cn=json.load(open(ROOT/'research/china.json'));nation=make_level(cn['features'],100000,'中国 · 省级拼图',100000);levels.append(nation)
|
||||
for p in sorted((ROOT/'data/raw').glob('*.json')):
|
||||
@@ -78,35 +20,45 @@ result={'source':'DataV.GeoAtlas','downloadDate':'2026-10-16','provinces':provin
|
||||
summary={'levels':len(levels),'countyLevels':len(levels)-1,'provincePieces':len(nation['regions']),'countyPieces':sum(len(l['regions']) for l in levels[1:]),'availableProvinces':len({l['province'] for l in levels[1:]}),'seaRoutes':sum(e[2] for l in levels for e in l['edges'])}
|
||||
(ROOT/'data/county-materials/summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2));print(summary)
|
||||
|
||||
# Formal release: only province and prefecture-level units, no county pieces.
|
||||
# Formal release: prefectures plus province-administered peers, so the province has no filtered-out gaps.
|
||||
citylevels=[nation]
|
||||
excluded=[]
|
||||
direct_summary=[]
|
||||
prefecture_index={'downloadDate':'2026-10-16','source':'DataV.GeoAtlas','levels':[]}
|
||||
for p in sorted((ROOT/'data/provinces-raw').glob('*.json')):
|
||||
code=int(p.stem);info=LOOKUP.get(code)
|
||||
if not info:continue
|
||||
fs=json.load(open(p)).get('features',[])
|
||||
cityfs=[f for f in fs if f.get('properties',{}).get('level')=='city' and int(f['properties'].get('adcode',0))%100==0]
|
||||
excluded.extend(f['properties']['name'] for f in fs if f not in cityfs)
|
||||
# These are the province endpoint's immediate children, not every county
|
||||
# nested inside a prefecture. District-only municipalities are added separately.
|
||||
children=[f for f in fs if f.get('properties',{}).get('level') in ('city','district') and f['properties'].get('parent',{}).get('adcode')==code]
|
||||
prefectures=[f for f in children if int(f['properties']['adcode'])%100==0]
|
||||
if not prefectures:continue
|
||||
cityfs=children
|
||||
assert len(cityfs)==len(fs),(code,'unclassified province children')
|
||||
direct=[f for f in cityfs if f not in prefectures]
|
||||
lv=make_level(cityfs,code,info['name'],code)
|
||||
if lv:
|
||||
# Missing county-only zones may disconnect prefectures: these are not land neighbors.
|
||||
lv['kind']='prefecture'
|
||||
direct_ids={str(f['properties']['adcode']) for f in direct}
|
||||
for r in lv['regions']:r['unitType']='province-direct' if r['id'] in direct_ids else 'prefecture'
|
||||
if direct:
|
||||
lv['playLabel']='省域拼图'
|
||||
lv['unitCaption']='地级行政区 · 省直辖地区'
|
||||
lv['scopeNote']='含'+str(len(direct))+'个省直辖县级地区,拼完整片省域。'
|
||||
lv['directRegionCount']=len(direct)
|
||||
direct_summary.append({'province':str(code),'names':[f['properties']['name'] for f in direct]})
|
||||
entries=[]
|
||||
for f in cityfs:
|
||||
prop=f['properties'];geometry=f['geometry'];polys=geometry['coordinates'] if geometry['type']=='MultiPolygon' else [geometry['coordinates']]
|
||||
entries.append({'id':str(prop['adcode']),'name':prop['name'],'unitType':'province-direct' if str(prop['adcode']) in direct_ids else 'prefecture','rings':len(polys),'holes':sum(len(poly)-1 for poly in polys)})
|
||||
prefecture_index['levels'].append({'id':str(code),'name':info['name'],'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'regions':entries})
|
||||
# Disconnected islands retain explicit game-only routes.
|
||||
for edge in lv['edges']:
|
||||
if edge[2]:edge[2]=2
|
||||
citylevels.append(lv)
|
||||
def inside(p,ring):
|
||||
x,y=p;hit=False
|
||||
for i,a in enumerate(ring):
|
||||
b=ring[(i+1)%len(ring)]
|
||||
if (a[1]>y)!=(b[1]>y) and x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0]:hit=not hit
|
||||
return hit
|
||||
for lv in citylevels:
|
||||
for r in lv['regions']:
|
||||
ring=r['rings'][0];minx,maxx=min(p[0] for p in ring),max(p[0] for p in ring);miny,maxy=min(p[1] for p in ring),max(p[1] for p in ring)
|
||||
candidates=[[minx+(maxx-minx)*i/14,miny+(maxy-miny)*j/14] for i in range(1,14) for j in range(1,14)]
|
||||
candidates=[p for p in candidates if inside(p,ring)]
|
||||
if candidates:r['label']=max(candidates,key=lambda p:min(dist(p,ring[k],ring[(k+1)%len(ring)]) for k in range(len(ring))))
|
||||
else:r['label']=r['center']
|
||||
r['label']=[round(v,3) for v in r['label']]
|
||||
hainan_offshore_inset(lv)
|
||||
place_labels(lv)
|
||||
# Preserve the source's unnamed maritime boundary features as a separate layer.
|
||||
minx,miny,scale=nation['projection'];decorations=[]
|
||||
for feature in cn['features']:
|
||||
@@ -117,5 +69,7 @@ nation['decorations']=decorations
|
||||
for lv in citylevels:lv.pop('projection',None)
|
||||
result={'source':'DataV.GeoAtlas','downloadDate':'2026-10-16','provinces':provinces,'levels':citylevels}
|
||||
(ROOT/'src/maps.js').write_text('window.MAP_DATA='+json.dumps(result,ensure_ascii=False,separators=(',',':'))+';\n')
|
||||
summary={'levels':len(citylevels),'prefectureLevels':len(citylevels)-1,'provincePieces':len(nation['regions']),'prefecturePieces':sum(len(l['regions']) for l in citylevels[1:]),'availableProvinces':len(citylevels)-1,'excludedCountyUnits':excluded,'countyMaterials':{'levels':len(levels)-1,'pieces':sum(len(l['regions']) for l in levels[1:]),'path':'data/county-materials/maps.js'}}
|
||||
(ROOT/'data/map-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2));print('Release:',{k:v for k,v in summary.items() if k!='excludedCountyUnits'})
|
||||
subprocess.run(['node',str(ROOT/'scripts/triangulate-holes.mjs')],check=True,cwd=ROOT)
|
||||
summary={'levels':len(citylevels),'prefectureLevels':len(citylevels)-1,'provincePieces':len(nation['regions']),'prefecturePieces':sum(len(l['regions']) for l in citylevels[1:]),'availableProvinces':len(citylevels)-1,'provinceDirectPieces':sum(len(v['names']) for v in direct_summary),'provinceDirectUnits':direct_summary,'countyMaterials':{'levels':len(levels)-1,'pieces':sum(len(l['regions']) for l in levels[1:]),'path':'data/county-materials/maps.js'}}
|
||||
(ROOT/'research/prefecture-map-index.json').write_text(json.dumps(prefecture_index,ensure_ascii=False,indent=2)+'\n')
|
||||
(ROOT/'data/map-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2));print('Release:',{k:v for k,v in summary.items() if k!='provinceDirectUnits'})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Convert the official NLSC county SHP ZIP to a build-only GeoJSON input.
|
||||
|
||||
Download https://maps.nlsc.gov.tw/pro/download.jsp's county EPSG:3824 ZIP
|
||||
into data/taiwan-raw/counties.zip first. Only Python's standard library is used.
|
||||
"""
|
||||
import json, pathlib, struct, zipfile
|
||||
ROOT=pathlib.Path(__file__).resolve().parent.parent
|
||||
folder=ROOT/'data/taiwan-raw'
|
||||
with zipfile.ZipFile(folder/'counties.zip') as z:
|
||||
names=z.namelist();dbf=z.read(next(n for n in names if n.lower().endswith('.dbf')))
|
||||
shp=z.read(next(n for n in names if n.lower().endswith('.shp')))
|
||||
prj=z.read(next(n for n in names if n.lower().endswith('.prj'))).decode()
|
||||
assert 'GEOGCS' in prj and 'TWD_1997' in prj and 'Degree' in prj
|
||||
count,header,size=struct.unpack_from('<IHH',dbf,4);fields=[];offset=32
|
||||
while dbf[offset]!=13:
|
||||
d=dbf[offset:offset+32];fields.append((d[:11].split(b'\0')[0].decode(),d[16]));offset+=32
|
||||
rows=[]
|
||||
for i in range(count):
|
||||
row=dbf[header+i*size:header+(i+1)*size];assert row[0]==32;pos=1;prop={}
|
||||
for name,length in fields:prop[name]=row[pos:pos+length].decode('utf-8').strip();pos+=length
|
||||
rows.append(prop)
|
||||
assert count==22 and struct.unpack_from('<i',shp,32)[0]==5
|
||||
geometries=[];offset=100
|
||||
while offset<len(shp):
|
||||
_,words=struct.unpack_from('>ii',shp,offset);part=shp[offset+8:offset+8+words*2];offset+=8+words*2
|
||||
assert struct.unpack_from('<i',part)[0]==5
|
||||
parts,points=struct.unpack_from('<ii',part,36);starts=list(struct.unpack_from('<'+'i'*parts,part,44))+[points];base=44+parts*4
|
||||
coords=[list(struct.unpack_from('<dd',part,base+i*16)) for i in range(points)]
|
||||
rings=[coords[starts[i]:starts[i+1]] for i in range(parts)]
|
||||
def signed(r):return sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(r,r[1:]))/2
|
||||
# SHP exterior rings are clockwise. Inner holes are not individual pieces.
|
||||
outers=[r for r in rings if signed(r)<0]
|
||||
assert outers and all(114<x<125 and 9<y<27 for r in outers for x,y in r)
|
||||
def inside(p,r):
|
||||
x,y=p;hit=False
|
||||
for a,b in zip(r,r[1:]):
|
||||
if (a[1]>y)!=(b[1]>y) and x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0]:hit=not hit
|
||||
return hit
|
||||
polys=[[r] for r in outers]
|
||||
for hole in [r for r in rings if signed(r)>0]:
|
||||
owners=[poly for poly in polys if inside(hole[0],poly[0])];assert len(owners)==1;owners[0].append(hole)
|
||||
geometries.append({'type':'MultiPolygon','coordinates':polys})
|
||||
assert len(geometries)==len(rows)
|
||||
trans=str.maketrans({'臺':'台','縣':'县','蘭':'兰','雲':'云','義':'义','園':'园','東':'东','蓮':'莲'})
|
||||
features=[]
|
||||
for prop,g in zip(rows,geometries):
|
||||
# These two counties belong to Fujian, not Taiwan Province.
|
||||
if prop['COUNTYCODE'] in ('09007','09020'):continue
|
||||
features.append({'type':'Feature','properties':{'adcode':'TW-'+prop['COUNTYCODE'],'sourceCode':prop['COUNTYCODE'],'name':prop['COUNTYNAME'].translate(trans),'level':'county-city'},'geometry':g})
|
||||
assert len(features)==20
|
||||
result={'type':'FeatureCollection','source':'NLSC','sourceURL':'https://data.gov.tw/dataset/7442','snapshot':'COUNTY_MOI_1090820','license':'政府資料開放授權條款第1版','features':features}
|
||||
(folder/'counties.geojson').write_text(json.dumps(result,ensure_ascii=False,separators=(',',':')))
|
||||
print('台湾县市输入:',len(features),'块;外环',sum(len(f['geometry']['coordinates']) for f in features))
|
||||
@@ -0,0 +1,15 @@
|
||||
// Deterministic Node diagnostic, not a mobile FPS / native acceptance test.
|
||||
const fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'),Module=require('node:module'),{performance}=require('node:perf_hooks'),M=require('matter-js');
|
||||
const args=process.argv.slice(2),source=args[0]||'src/game.js';
|
||||
const mod=new Module(path.resolve('src/profile-game.cjs'));mod.filename=path.resolve('src/profile-game.cjs');mod.paths=module.paths;mod._compile(fs.readFileSync(source,'utf8'),mod.filename);const {Game}=mod.exports;
|
||||
const box={window:{}};vm.runInNewContext(fs.readFileSync('src/maps.js','utf8'),box);const maps=box.window.MAP_DATA.levels;
|
||||
let county;vm.runInNewContext(fs.readFileSync('src/county/140500.js','utf8'),{window:{PuzzleAtlas:{register(l){county=l;}}}});
|
||||
const methods=['uprightOnce','guardPenetrations','tryMerges','attractNearby','updateSupport','stepGrowth','limitMotion','pose','pocketPlan','snapPlan'];
|
||||
function run(level,upright,shake){let seed=74;const rng=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;};const g=new Game(level,440,680,4,()=>{},rng,1,2),cost={},calls={},ticks=[],collisions={count:0},transforms={count:0};let measuring=false;
|
||||
for(const key of methods){const f=g[key];g[key]=function(...args){if(!measuring)return f.apply(this,args);calls[key]=(calls[key]||0)+1;const start=performance.now();try{return f.apply(this,args);}finally{cost[key]=(cost[key]||0)+performance.now()-start;}};}
|
||||
const collide=M.Collision.collides,angle=M.Body.setAngle;M.Collision.collides=function(...args){if(measuring)collisions.count++;return collide.apply(this,args);};M.Body.setAngle=function(...args){if(measuring)transforms.count++;return angle.apply(this,args);};g.start();let awake=0,samples=0;
|
||||
try{for(let i=0;i<2400&&g.status==='playing';i++){if(g.pending&&i%35===0){const serial=g.pending.serial;g.movePending(35+rng()*370,serial);g.holdPending(serial);g.releasePending(serial);}if(i===1200)upright&&g.uprightOnce();if(shake&&i>=1200&&i%40===0)g.swipe({x:220,y:560},{x:i%80?100:-100,y:-30},65);measuring=i>=1800;const start=performance.now();g.tick(1000/60);if(measuring){ticks.push(performance.now()-start);awake+=g.groups.filter(p=>!p.body.isSleeping).length;samples+=g.groups.length;}}}finally{M.Collision.collides=collide;M.Body.setAngle=angle;}
|
||||
ticks.sort((a,b)=>a-b);const result={id:level.id,upright,shake,status:g.status,groups:g.groups.length,dropped:g.dropped,parts:g.groups.reduce((n,p)=>n+p.body.parts.length,0),sampleTicks:ticks.length,meanMs:ticks.reduce((n,t)=>n+t,0)/ticks.length,p95Ms:ticks[Math.floor(ticks.length*.95)],awakeRatio:awake/samples,collisionCalls:collisions.count,angleGeometryTransforms:transforms.count,costMs:cost,calls};g.destroy();return result;
|
||||
}
|
||||
const out=[];for(const level of [maps[0],maps.find(l=>l.id==='420000'),county])for(const shake of [false,true])for(const upright of [false,true])out.push(run(level,upright,shake));
|
||||
const report={scope:'Node physics only; upright means one button press at tick 1200, not a mode; method times are inclusive and instrumentation adds overhead; no rendering/video or native FPS claim',source,cases:out};if(args[1])fs.writeFileSync(args[1],JSON.stringify(report,null,2)+'\n');else console.log(JSON.stringify(report,null,2));
|
||||
@@ -0,0 +1,2 @@
|
||||
# Only needed to regenerate src/county assets; not needed by npm run build.
|
||||
shapely==2.1.2
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const root=path.resolve(process.env.PREVIEW_DIR||'src');
|
||||
const root=path.resolve(process.env.PREVIEW_DIR||'dist');
|
||||
if(!fs.existsSync(path.join(root,'index.html')))throw Error('请先运行 npm run build,再启动预览。');
|
||||
const mime={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.json':'application/json'};
|
||||
http.createServer((req,res)=>{let p;try{p=decodeURIComponent(new URL(req.url,'http://localhost').pathname);}catch{res.writeHead(400).end();return;}const file=path.resolve(root,'.'+(p==='/'?'/index.html':p));if(!file.startsWith(root+path.sep)){res.writeHead(403).end();return;}fs.readFile(file,(err,b)=>{if(err){res.writeHead(404).end('Not found');return;}res.writeHead(200,{'Content-Type':mime[path.extname(file)]||'application/octet-stream','Cache-Control':'no-store'});res.end(b);});}).listen(4173,'127.0.0.1',()=>console.log('山河拼图预览:http://localhost:4173'));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Preparation only: static hole-aware convex parts and robust decomposition fallback.
|
||||
import fs from 'node:fs';import earcut from 'earcut';import assert from 'node:assert/strict';import {createHash} from 'node:crypto';import M from 'matter-js';import gameModule from '../src/game.js';
|
||||
function inside([x,y],ring){let hit=false;for(let i=0,j=ring.length-1;i<ring.length;j=i++){const a=ring[i],b=ring[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])hit=!hit;}return hit;}
|
||||
function triangulate(region){region.physics=[];let assigned=0;for(const outer of region.rings){const holes=(region.holes||[]).filter(h=>inside(h[0],outer));assigned+=holes.length;const flat=earcut.flatten([outer,...holes]),indices=earcut(flat.vertices,flat.holes,2);assert.ok(earcut.deviation(flat.vertices,flat.holes,2,indices)<1e-8);for(let i=0;i<indices.length;i+=3)region.physics.push(indices.slice(i,i+3).map(j=>[flat.vertices[j*2],flat.vertices[j*2+1]]));}assert.equal(assigned,(region.holes||[]).length);}
|
||||
let regions=0,parts=0,fallbacks=0;const index=JSON.parse(fs.readFileSync('research/county-map-index.json','utf8'));
|
||||
for(const name of fs.readdirSync('src/county').filter(n=>n.endsWith('.js'))){const path='src/county/'+name,level=JSON.parse(fs.readFileSync(path,'utf8').replace(/^window\.PuzzleAtlas\.register\(/,'').replace(/\);\s*$/,''));
|
||||
for(const region of level.regions)if(region.holes&®ion.holes.length){regions++;triangulate(region);parts+=region.physics.length;}
|
||||
// Exercise the exact game's decomposition at common viewport scales. A valid
|
||||
// but awkward outline must not leave missing physical parts after max-depth.
|
||||
const fallback=new Set();for(const [w,h] of [[320,568],[340,560],[440,680]]){const game=new gameModule.Game(level,w,h,4);for(let id=0;id<level.regions.length;id++){let warned=false;const previous=console.warn;console.warn=(...args)=>{if(String(args[0]).includes('quickDecomp'))warned=true;else previous(...args);};let group;try{group=game.createGroup([id],{tx:0,ty:0,angle:0});}finally{console.warn=previous;}if(warned)fallback.add(id);M.Composite.remove(game.engine.world,group.body);game.groups=[];}game.destroy();}
|
||||
for(const id of fallback){triangulate(level.regions[id]);fallbacks++;}
|
||||
const encoded='window.PuzzleAtlas.register('+JSON.stringify(level)+');\n';fs.writeFileSync(path,encoded);const entry=index.levels.find(l=>l.id===level.id);entry.assetSha256=createHash('sha256').update(encoded).digest('hex');entry.collisionFallbacks=Array.from(fallback,id=>level.regions[id].id);
|
||||
}
|
||||
fs.writeFileSync('research/county-map-index.json',JSON.stringify(index)+'\n');console.log('County physics:',regions,'hole regions,',parts,'hole parts;',fallbacks,'static decomposition fallbacks');
|
||||
@@ -0,0 +1,10 @@
|
||||
// Build-only triangulation: the game consumes static convex polygons, no library.
|
||||
import earcut from 'earcut';import fs from 'node:fs';import assert from 'node:assert/strict';
|
||||
const filename='src/maps.js',data=JSON.parse(fs.readFileSync(filename,'utf8').replace(/^window\.MAP_DATA=/,'').replace(/;\s*$/,''));
|
||||
function inside([x,y],ring){let hit=false;for(let i=0,j=ring.length-1;i<ring.length;j=i++){const a=ring[i],b=ring[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])hit=!hit;}return hit;}
|
||||
let count=0;
|
||||
for(const lv of data.levels)for(const region of lv.regions){if(!region.holes||!region.holes.length)continue;region.physics=[];let assigned=0;
|
||||
for(const outer of region.rings){const holes=region.holes.filter(h=>inside(h[0],outer));assigned+=holes.length;if(!holes.length){region.physics.push(outer);continue;}const flat=earcut.flatten([outer,...holes]),indices=earcut(flat.vertices,flat.holes,2);assert.ok(earcut.deviation(flat.vertices,flat.holes,2,indices)<1e-8);
|
||||
for(let i=0;i<indices.length;i+=3)region.physics.push(indices.slice(i,i+3).map(j=>[flat.vertices[j*2],flat.vertices[j*2+1]]));
|
||||
}assert.equal(assigned,region.holes.length);count++;}
|
||||
fs.writeFileSync(filename,'window.MAP_DATA='+JSON.stringify(data)+';\n');console.log('保留内嵌县市的环形碰撞区域:'+count+'块');
|
||||
+253
-78
@@ -1,115 +1,204 @@
|
||||
(function(){
|
||||
'use strict';
|
||||
var $=function(id){return document.getElementById(id);},data=window.MAP_DATA,C=window.PuzzleCore,Engine=window.PuzzleGame;
|
||||
var $=function(id){return document.getElementById(id);},data=window.MAP_DATA,C=window.PuzzleCore,Engine=window.PuzzleGame,Atlas=window.PuzzleAtlas;
|
||||
var colors=['#c3d4b4','#e3c78f','#d2b5a5','#b5cbd0','#d2c69d','#b1c5a0','#d9bca6','#bdc7ac','#d7c5c5','#bfd0ba'];
|
||||
var provinceCode='330000',mode='province',speed=0,bounce=1,result=null,level=data.levels[0],game=null,screen='entry',records={},pausedByModal=false,toastTimer=null;
|
||||
var canvas=$('board'),ctx=canvas.getContext('2d'),w=0,h=0,dpr=1,miniTool=null,storageNative=false,storeReady=false,saveChain=Promise.resolve();
|
||||
var reducedMotion=typeof window.matchMedia==='function'?window.matchMedia('(prefers-reduced-motion: reduce)'):null,modeGesture=null,suppressModeClickUntil=0;
|
||||
var selectedProvince=null,selectedCity=null,pendingCity=null,mapRequest=0,journalProvince='',entryMap=data.levels[0],mode='province',bounce=1,snap=0,difficultyDraft=null,result=null,level=data.levels[0],game=null,screen='entry',records={},triumphs={},pausedByModal=false,toastTimer=null,journalFilter='all',journalPage=0;
|
||||
var canvas=$('board'),ctx=canvas.getContext('2d'),w=0,h=0,dpr=1,miniTool=null,storageNative=false,storeReady=false,storeReadable=true,saveChain=Promise.resolve();
|
||||
var videoCapture=null,videoJob=null,videoOutput=null,videoSettled=false,videoNotice='',victoryAge=0;
|
||||
var reducedMotion=typeof window.matchMedia==='function'?window.matchMedia('(prefers-reduced-motion: reduce)'):null,renderedMap=null;
|
||||
var holdTimer=null,inputDirection=0,tankGesture=null,swipeTrace=null,keys={left:false,right:false},rafId=0,last=0,uiAt=0,lastLabelBoxes=[],labelObstacles=[];
|
||||
// Small DOM-backed diagnostics survive a failed frame without exposing player
|
||||
// storage. Update at 2 Hz; no per-frame console output or network reporting.
|
||||
var loopHealth={build:'2.2.0-direct-upright-20261018',frames:0,stage:'idle',physicsMs:0,drawMs:0,videoMs:0,error:null},healthAt=-Infinity;
|
||||
function loopNow(){return window.performance&&typeof window.performance.now==='function'?window.performance.now():Date.now();}
|
||||
function writeLoopHealth(force){var now=loopNow();if(!force&&now-healthAt<500)return;healthAt=now;canvas.setAttribute('data-loop-health',JSON.stringify({build:loopHealth.build,frames:loopHealth.frames,stage:loopHealth.stage,physicsMs:loopHealth.physicsMs,drawMs:loopHealth.drawMs,videoMs:loopHealth.videoMs,error:loopHealth.error,status:game?game.status:screen,level:level.id,dropped:game?game.dropped:0,groups:game?game.groups.length:0,uprightCount:game?game.uprightCount:0}));}
|
||||
function recordLoopError(stage,error){loopHealth.error={stage:stage,message:String(error&&error.message||error),stack:String(error&&error.stack||'').slice(0,3000)};writeLoopHealth(true);if(window.console&&typeof console.error==='function')console.error('Shanhe '+stage,error);}
|
||||
function captureVideoFrame(force){if(!videoCapture)return;var started=loopNow();try{videoCapture.capture(canvas,videoMeta(),!!force);}catch(error){recordLoopError('video',error);try{videoCapture.dispose();}catch(ignored){}videoCapture=null;videoNotice='本局视频记录暂不可用,可分享成绩图';toast('视频记录遇到问题,游戏可继续');}finally{loopHealth.videoMs=loopNow()-started;}}
|
||||
function pauseAfterFrameError(error){recordLoopError(loopHealth.stage,error);stopInput();if(!game||game.endReason)return;game.status='paused';pausedByModal=true;openModal('TAKE A BREATH','<h2>画面暂时停了一下</h2><p>已暂停并保留本局进度,可以尝试继续,或结束旅程保存成绩。</p>',[{label:'继续本局',primary:true,action:function(){closeModal();}},{label:'结束旅程',action:finishJourney}]);}
|
||||
function motionAllowed(){return !reducedMotion||!reducedMotion.matches;}
|
||||
function pulseButton(button,kind){if(!button||button.disabled||!motionAllowed())return;button.classList.remove('tap-pop','bounce-pop');void button.offsetWidth;button.classList.add(kind||'tap-pop');}
|
||||
document.addEventListener('animationend',function(e){if(e.target.classList){e.target.classList.remove('tap-pop','bounce-pop');}});
|
||||
document.addEventListener('click',function(e){var button=e.target.closest&&e.target.closest('button');pulseButton(button,button&&(button.hasAttribute('data-bounce')||button.id==='up')?'bounce-pop':'tap-pop');});
|
||||
function toast(msg){$('toast').textContent=msg;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(function(){$('toast').hidden=true;},3200);}
|
||||
document.addEventListener('animationend',function(e){if(e.target.classList){e.target.classList.remove('tap-pop','bounce-pop','map-arrive','difficulty-arrive');}});
|
||||
document.addEventListener('click',function(e){var button=e.target.closest&&e.target.closest('button');pulseButton(button,button&&(button.hasAttribute('data-bounce'))?'bounce-pop':'tap-pop');});
|
||||
function toast(msg,quick){var node=$('toast');clearTimeout(toastTimer);node.classList.remove('toast-quick');node.textContent=msg;node.hidden=false;if(quick){void node.offsetWidth;node.classList.add('toast-quick');}toastTimer=setTimeout(function(){node.hidden=true;},quick?800:3200);}
|
||||
function version(options){return Number(options&&options.miniToolEnv&&options.miniToolEnv.buildVersion)||0;}
|
||||
var storePromise;
|
||||
function mergeRecords(a,b){Object.keys(b).forEach(function(k){a[k]=Math.max(a[k]||0,b[k]);});return a;}
|
||||
function parseStored(raw){try{return raw?JSON.parse(raw):{};}catch(e){return {};}}
|
||||
function localStored(key){try{return parseStored(window.localStorage.getItem(key));}catch(e){return {};}}
|
||||
async function writeProgress(){if(!storeReadable)throw Error('Storage read incomplete');var serialized=JSON.stringify({records:records,triumphs:triumphs});if(storageNative)await miniTool.setStorage({key:'shanhe-progress-v2',data:serialized});else window.localStorage.setItem('shanhe-progress-v2',serialized);}
|
||||
async function initStore(){var xhs=window.xhs;miniTool=xhs&&xhs.miniTool;var v=version(xhs&&xhs.launchOptions);if(!v&&miniTool&&typeof miniTool.getLaunchOptions==='function'){try{v=version(await miniTool.getLaunchOptions());}catch(e){v=0;}}
|
||||
storageNative=!!(Math.floor(v/1000)>=9460&&miniTool&&typeof miniTool.getStorage==='function'&&typeof miniTool.setStorage==='function');
|
||||
var old={};try{var legacy=window.localStorage.getItem('shanhe-records-v1');old=C.safeRecord(legacy?JSON.parse(legacy):{});}catch(e){}var loaded={};if(storageNative){try{var r=await miniTool.getStorage({key:'shanhe-records-v1'});loaded=C.safeRecord(r&&r.data?JSON.parse(r.data):{});}catch(e){}}else loaded=old;
|
||||
records=mergeRecords(mergeRecords(loaded,old),records);storeReady=true;if(storageNative&&Object.keys(old).length){try{await miniTool.setStorage({key:'shanhe-records-v1',data:JSON.stringify(records)});}catch(e){toast('旧纪录已在当前页面恢复,迁移保存暂不可用');}}updateUI();}
|
||||
function recordKey(){return level.id+':'+speed+(bounce?':'+bounce:'');}
|
||||
function saveRecord(){var key=recordKey();records[key]=Math.max(records[key]||0,game.score);updateUI();saveChain=saveChain.then(async function(){await storePromise;try{var serialized=JSON.stringify(records);if(storageNative)await miniTool.setStorage({key:'shanhe-records-v1',data:serialized});else window.localStorage.setItem('shanhe-records-v1',serialized);}catch(e){toast('本次纪录仅保留在当前页面,存储暂不可用');}});}
|
||||
function shortName(name){return name.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|省|市$/g,'');}
|
||||
var old=C.safeRecord(localStored('shanhe-records-v1')),loaded=C.mergeProgress({records:old},localStored('shanhe-progress-v2')),nativeLegacy={};
|
||||
if(storageNative){try{var legacy=await miniTool.getStorage({key:'shanhe-records-v1'});nativeLegacy=C.safeRecord(parseStored(legacy&&legacy.data));}catch(e){}loaded=C.mergeProgress(loaded,{records:nativeLegacy});try{var current=await miniTool.getStorage({key:'shanhe-progress-v2'});loaded=C.mergeProgress(loaded,parseStored(current&¤t.data));}catch(e){storeReadable=false;toast('战绩读取暂不可用,新成绩先保留在当前页面');}}
|
||||
loaded=C.mergeProgress(loaded,{records:records,triumphs:triumphs});records=loaded.records;triumphs=loaded.triumphs;storeReady=true;
|
||||
if(storeReadable&&(Object.keys(old).length||Object.keys(nativeLegacy).length||(storageNative&&(Object.keys(loaded.records).length||Object.keys(loaded.triumphs).length)))){try{await writeProgress();}catch(e){toast('战绩已在当前页面恢复,迁移保存暂不可用');}}updateUI();if($('modal').classList.contains('journal-modal')&&$('modal').hasAttribute('open'))renderJournal();}
|
||||
function recordKey(){return level.id+':4:'+bounce+':s'+snap;}
|
||||
function bestRecord(){var best=records[recordKey()]||0;if(snap===2)best=Math.max(best,records[level.id+':4'+(bounce?':'+bounce:'')]||0);return best;}
|
||||
function saveRecord(){var progress=C.finishProgress({records:records,triumphs:triumphs},game.level.id,game.snapIndex,game.bounceIndex,game.score,game.endReason==='won');records=progress.records;triumphs=progress.triumphs;updateUI();saveChain=saveChain.then(async function(){await storePromise;try{await writeProgress();}catch(e){toast('本次战绩仅保留在当前页面,存储暂不可用');}});}
|
||||
function shortName(name){return name.replace(/(?:特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|省|市)$/,'');}
|
||||
function displayLevelName(){return selectedCity?shortName(level.provinceName)+' · '+shortName(level.name):selectedProvince?shortName(level.name):level.name;}
|
||||
function sizeCanvas(c,ww,hh){c.width=Math.round(ww*dpr);c.height=Math.round(hh*dpr);var context=c.getContext('2d');context.setTransform(dpr,0,0,dpr,0,0);return context;}
|
||||
|
||||
function layout(){
|
||||
dpr=Math.min(window.devicePixelRatio||1,2);
|
||||
var bodyStyle=window.getComputedStyle(document.body),padding=(parseFloat(bodyStyle.paddingTop)||0)+(parseFloat(bodyStyle.paddingBottom)||0);
|
||||
var viewport=window.visualViewport;var visibleHeight=viewport&&typeof viewport.height==='number'&&viewport.scale===1?Math.min(window.innerHeight,viewport.height):window.innerHeight;var available=Math.max(200,visibleHeight-padding);document.documentElement.style.setProperty('--content-top',(parseFloat(bodyStyle.paddingTop)||0)+'px');document.documentElement.style.setProperty('--game-height',available+'px');document.body.classList.toggle('compact-entry',available<700||window.innerWidth<=720);document.body.classList.toggle('tiny-entry',available<540);
|
||||
if(screen==='entry'){fitEntryMap();drawReference();return;}if(screen==='result'){fitCard();return;}
|
||||
if(screen==='entry'){var main=document.querySelector('.atlas-main'),scene=document.querySelector('.atlas-scene'),room=main.clientHeight-document.querySelector('.atlas-navigation').offsetHeight-document.querySelector('.atlas-tip').offsetHeight-document.querySelector('.atlas-actions').offsetHeight-8;document.documentElement.style.setProperty('--atlas-scene-height',Math.max(80,Math.min(room,window.innerWidth<=720?scene.clientWidth*1.18:room))+'px');drawReference();return;}if(screen==='result'){fitCard();return;}
|
||||
var r=canvas.getBoundingClientRect(),nw=Math.round(r.width),nh=Math.round(r.height);
|
||||
if(nw<1||nh<1)return;
|
||||
var changed=w!==nw||h!==nh;w=nw;h=nh;sizeCanvas(canvas,w,h);labelObstacles=['board-status'].map(function(id){var box=$(id).getBoundingClientRect();return {x:box.left-r.left-3,y:box.top-r.top-2,w:box.width+6,h:box.height+4};});var danger=document.querySelector('.danger-label').getBoundingClientRect();labelObstacles.push({x:danger.left-r.left-3,y:danger.top-r.top-2,w:danger.width+6,h:danger.height+4});
|
||||
if(game&&changed)game.resize(w,h);drawNext();if(game)draw();
|
||||
}
|
||||
function fitEntryMap(){
|
||||
var landscape=window.innerWidth>=600&&window.innerHeight<=520;if(window.innerWidth>720&&!landscape){document.body.classList.remove('map-roomy');return;}
|
||||
var main=document.querySelector('.entry-main'),setup=document.querySelector('.setup'),style=window.getComputedStyle(main),inside=main.clientHeight-(parseFloat(style.paddingTop)||0)-(parseFloat(style.paddingBottom)||0);
|
||||
var space=landscape?inside:inside-setup.offsetHeight-10,preview=Math.max(44,Math.min(220,space));document.documentElement.style.setProperty('--entry-preview-height',Math.floor(preview)+'px');document.body.classList.toggle('map-roomy',!landscape&&preview>=180);
|
||||
var changed=w!==nw||h!==nh;w=nw;h=nh;sizeCanvas(canvas,w,h);var line=Engine.dangerLine(h);$('board-status').style.top=Math.min(h-18,line+20)+'px';$('drop-countdown').style.top=Math.round(line-23)+'px';document.querySelector('.danger-label').style.top=Math.round(line-10)+'px';labelObstacles=['board-status'].map(function(id){var box=$(id).getBoundingClientRect();return {x:box.left-r.left-3,y:box.top-r.top-2,w:box.width+6,h:box.height+4};});labelObstacles.push({x:0,y:0,w:w,h:line+2});var danger=document.querySelector('.danger-label').getBoundingClientRect();labelObstacles.push({x:danger.left-r.left-3,y:danger.top-r.top-2,w:danger.width+6,h:danger.height+4});
|
||||
if(game&&changed){stopInput();game.resize(w,h);}drawNext();if(game)draw();
|
||||
}
|
||||
function openModal(kicker,html,actions){
|
||||
stopInput();if(game&&game.status==='playing'){game.status='paused';pausedByModal=true;cancelAnimationFrame(rafId);rafId=0;}
|
||||
$('modal').classList.remove('province-picker-modal');$('modal-kicker').textContent=kicker;$('modal-content').innerHTML=html;$('modal-actions').innerHTML='';
|
||||
(actions||[]).forEach(function(a){var b=document.createElement('button');b.className=a.primary?'primary':'secondary';b.textContent=a.label;b.addEventListener('click',a.action);$('modal-actions').appendChild(b);});
|
||||
$('modal').classList.remove('difficulty-modal','difficulty-arrive','journal-modal');$('modal').removeAttribute('aria-labelledby');$('modal-kicker').textContent=kicker;$('modal-content').innerHTML=html;$('modal-actions').innerHTML='';
|
||||
(actions||[]).forEach(function(a){var b=document.createElement('button');b.className=a.primary?'primary':'secondary';b.textContent=a.label;b.addEventListener('click',a.action);if(a.id)b.id=a.id;$('modal-actions').appendChild(b);});
|
||||
var d=$('modal');if(!d.hasAttribute('open')){if(typeof d.showModal==='function')d.showModal();else{d.setAttribute('open','');d.classList.add('modal-fallback');}}
|
||||
updateUI();
|
||||
}
|
||||
function closeModal(resume){$('province-picker').classList.remove('expanded');var d=$('modal');if(typeof d.close==='function')d.close();else{d.removeAttribute('open');d.classList.remove('modal-fallback');}if(pausedByModal&&resume!==false&&game&&game.status==='paused'){game.status='playing';last=0;schedule();}pausedByModal=false;updateUI();}
|
||||
$('modal').addEventListener('cancel',function(e){e.preventDefault();closeModal();});$('close-modal').addEventListener('click',function(){closeModal();});
|
||||
function start(){
|
||||
stopInput();result=null;lastLabelBoxes=[];if(game)game.destroy();game=null;screen='game';document.body.classList.add('is-playing');$('entry-screen').hidden=true;$('result-screen').hidden=true;$('game-screen').hidden=false;window.scrollTo(0,0);
|
||||
$('board-status').textContent='轻划后松手,让山河随惯性相遇';$('tank').style.transform='translateX(0px)';layout();
|
||||
game=new Engine.Game(level,w,h,speed,onGameEvent,null,bounce);game.start();last=0;drawNext();updateUI();schedule();
|
||||
function closeModal(resume){difficultyDraft=null;var d=$('modal');if(typeof d.close==='function')d.close();else{d.removeAttribute('open');d.classList.remove('modal-fallback');}if(pausedByModal&&resume!==false&&game&&game.status==='paused'){game.status='playing';last=0;schedule();}pausedByModal=false;updateUI();}
|
||||
function dismissModal(){if(difficultyDraft&&difficultyDraft.starting&&screen==='game'){closeModal(false);pausedByModal=true;pause();}else closeModal();}
|
||||
$('modal').addEventListener('cancel',function(e){e.preventDefault();dismissModal();});$('close-modal').addEventListener('click',dismissModal);
|
||||
function detailPlayable(){return !!selectedProvince&&!pendingCity&&level.id===(selectedCity||selectedProvince);}
|
||||
function detailLabel(){return level.playLabel||'地级拼图';}
|
||||
function start(event){var button=event&&event.currentTarget;openDifficulty(true,button&&button.getBoundingClientRect?button.getBoundingClientRect():null);}
|
||||
function launchJourney(){
|
||||
if(selectedProvince&&!detailPlayable())return;
|
||||
loopHealth.frames=0;loopHealth.error=null;healthAt=-Infinity;victoryAge=0;resetVideo();videoCapture=new window.PuzzleVideo.Capture({snapshot:videoScene,render:renderVideoScene});stopInput();result=null;lastLabelBoxes=[];if(game)game.destroy();game=null;screen='game';document.body.classList.add('is-playing');$('entry-screen').hidden=true;$('result-screen').hidden=true;$('game-screen').hidden=false;window.scrollTo(0,0);
|
||||
$('board-status').textContent='红线下方轻划,晃动整框山河';$('tank').style.transform='translateX(0px)';layout();
|
||||
game=new Engine.Game(level,w,h,4,onGameEvent,null,bounce,snap);game.start();last=0;drawNext();updateUI();draw();captureVideoFrame(true);schedule();
|
||||
}
|
||||
function showEntry(){stopInput();cancelAnimationFrame(rafId);rafId=0;if(game)game.destroy();game=null;lastLabelBoxes=[];screen='entry';document.body.classList.remove('is-playing');$('game-screen').hidden=true;$('result-screen').hidden=true;$('entry-screen').hidden=false;result=null;$('tank').style.transform='translateX(0px)';window.scrollTo(0,0);updateUI();layout();}
|
||||
function showEntry(){resetVideo();document.querySelectorAll('#entry-screen .tap-pop,#entry-screen .bounce-pop').forEach(function(b){b.classList.remove('tap-pop','bounce-pop');});stopInput();cancelAnimationFrame(rafId);rafId=0;if(game)game.destroy();game=null;lastLabelBoxes=[];screen='entry';document.body.classList.remove('is-playing');$('game-screen').hidden=true;$('result-screen').hidden=true;$('entry-screen').hidden=false;result=null;$('tank').style.transform='translateX(0px)';window.scrollTo(0,0);updateUI();layout();}
|
||||
function finishJourney(){closeModal(false);if(game)game.finish();}
|
||||
function pause(){if(!game||screen!=='game'||$('modal').hasAttribute('open'))return;if(game.endReason){showResult();return;}openModal('TAKE A BREATH','<h2>山河等你回来</h2><p>'+level.name+' · '+Engine.SPEEDS[speed].name+' · '+Engine.BOUNCES[bounce].name+'</p><p>暂停不计时。随时结束,也能带走这一程的成绩图。</p>',[{label:'继续游戏',primary:true,action:function(){closeModal();}},{label:'结束旅程',action:finishJourney},{label:'重新开局',action:function(){closeModal(false);start();}}]);}
|
||||
function pause(event){if(!game||screen!=='game'||$('modal').hasAttribute('open'))return;if(game.endReason){if(event&&event.type==='click')showResult();return;}openModal('TAKE A BREATH','<h2>山河等你回来</h2><p>'+displayLevelName()+' · '+'随心落子 · '+game.snap.name+' · '+game.bounce.name+' · 回正 '+game.uprightCount+' 次'+'</p><p>暂停不计时。随时结束,也能带走这一程的成绩图。</p>',[{label:'继续游戏',primary:true,action:function(){closeModal();}},{label:'结束旅程',action:finishJourney},{label:'重新开局',action:start}]);}
|
||||
function onGameEvent(type,detail){
|
||||
if(type==='spawn'){drawNext();if(game.queue.length===0)$('board-status').textContent='碎片已全部落下 · 继续晃动容器';}
|
||||
else if(type==='merge')$('board-status').textContent=(detail.sea===1?'跨海连接':detail.sea===2?'隔区连接':'边缘吻合')+(game.chain>1?' · '+game.chain+' 连合':'');
|
||||
if(type==='prepare'){drawNext();}
|
||||
else if(type==='spawn'){drawNext();if(game.queue.length===0)$('board-status').textContent='山河已到齐 · 继续晃动,慢慢拼合';}
|
||||
else if(type==='merge')$('board-status').textContent=(detail.pocket?'留洞补位':detail.sea===1?'跨海连接':detail.sea===2?'隔区连接':'边缘吻合')+(game.chain>1?' · '+game.chain+' 连合':'');
|
||||
else if(type==='zoom')$('board-status').textContent='拼合区域变大 · 视野自动缩放';
|
||||
else if(type==='end'){stopInput();saveRecord();showResult();}
|
||||
else if(type==='milestone')$('board-status').textContent=detail.names.join(' · ')+' · 拼合完成';
|
||||
else if(type==='end'){stopInput();saveRecord();if(detail.won){$('board-status').textContent=level.regions.length+' 块山河,完整相遇';drawNext();victoryAge=0;last=0;schedule();}else showResult();}
|
||||
updateUI();
|
||||
}
|
||||
function updateUI(){
|
||||
var key=recordKey(),best=records[key],config=Engine.SPEEDS[speed];$('entry-screen').classList.toggle('city-mode',mode==='city');var destination=data.levels.find(function(l){return l.id===provinceCode;});$('province-name').textContent=destination?destination.name:'浙江省';$('province-count').textContent=(destination?destination.regions.length:11)+' 个地级单元';
|
||||
$('entry-best').textContent=best?best.toLocaleString('zh-CN'):'—';$('journey-count').textContent=level.regions.length+' 块山河';$('map-caption').textContent=level.name+' · '+level.regions.length+' 块等你拼完整';
|
||||
$('city-select-panel').hidden=mode!=='city';$('city-note').textContent='27 个省区可选 · 仅拼地级市、自治州、地区、盟';
|
||||
$('multiplier').textContent='得分 ×'+config.multiplier;var comments=['慢慢来,也能看见山河','找到自己的拼图节奏','眼疾手快,山河相逢','留给地理高手的挑战'];$('speed-note').textContent=config.interval/1000+' 秒落下一块 · '+comments[speed];
|
||||
document.querySelectorAll('[data-mode]').forEach(function(b){var selected=b.getAttribute('data-mode')===mode;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));});
|
||||
document.querySelectorAll('[data-speed]').forEach(function(b){var selected=Number(b.getAttribute('data-speed'))===speed;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));});
|
||||
document.querySelectorAll('[data-bounce]').forEach(function(b){var selected=Number(b.getAttribute('data-bounce'))===bounce;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));});
|
||||
$('entry-screen').classList.toggle('province-selected',!!selectedProvince);$('entry-screen').classList.toggle('fine-map',!!selectedProvince&&mode!=='city'&&mode!=='province');var best=bestRecord(),choice=difficultyDraft||{bounce:bounce,snap:snap},unavailable=!!selectedProvince&&!detailPlayable();
|
||||
$('entry-best').textContent=!unavailable&&best?best.toLocaleString('zh-CN'):'—';
|
||||
$('atlas-title').textContent=selectedProvince?shortName(entryMap.name):'中国';$('atlas-mode').textContent=pendingCity?'正在展开':selectedProvince?(unavailable?'暂未开放':detailLabel()):'省级拼图';
|
||||
$('journey-count').innerHTML=(unavailable&&!pendingCity)?'<b>—</b> 块山河':'<b>'+level.regions.length+'</b> 块山河';
|
||||
$('map-caption').textContent=(unavailable&&!pendingCity)?'暂未开放细分关卡':selectedProvince?(level.unitCaption||'地级市 · 自治州 · 地区 · 盟'):'34 个省级行政区';
|
||||
$('atlas-back').hidden=!selectedProvince;$('atlas-back').disabled=!selectedProvince;var backName=selectedCity?shortName(Atlas.get(selectedProvince).name)+'地图':'全国地图';$('atlas-back-label').textContent=backName;$('atlas-back').setAttribute('aria-label','返回'+backName);$('start').disabled=unavailable;$('start-label').textContent=pendingCity?'地图展开中':unavailable?'暂未开放':'开启旅程';$('reference').setAttribute('aria-busy',String(!!pendingCity));
|
||||
$('atlas-tip').textContent=pendingCity?'正在展开'+shortName(Atlas.meta(pendingCity).name)+'的县区地图…':unavailable?'暂缺可靠细分轮廓,可返回上级地图。':selectedProvince?(mode==='city'?'直接出发拼这片省域,或点击地级市继续选县区。':level.scopeNote||'目的地已选好,出发去拼这片山河。'):'点击省区,拼一片家乡;或直接出发,拼完整个中国。';
|
||||
$('difficulty-summary').textContent=Engine.SNAP_DIFFICULTIES[snap].name+' · '+Engine.BOUNCES[bounce].name;
|
||||
if($('difficulty-destination'))$('difficulty-destination').textContent=displayLevelName()+' · '+level.regions.length+' 块山河';
|
||||
if($('snap-note'))$('snap-note').textContent=Engine.SNAP_DIFFICULTIES[choice.snap].note;
|
||||
document.querySelectorAll('[data-snap]').forEach(function(b){var selected=Number(b.getAttribute('data-snap'))===choice.snap;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));});
|
||||
document.querySelectorAll('[data-bounce]').forEach(function(b){var selected=Number(b.getAttribute('data-bounce'))===choice.bounce;b.classList.toggle('selected',selected);b.setAttribute('aria-pressed',String(selected));});
|
||||
$('pause').disabled=!game;
|
||||
$('upright-once').disabled=!game||game.status!=='playing'||!game.groups.length;
|
||||
if(!game)return;
|
||||
$('score').textContent=game.score.toLocaleString('zh-CN');$('merged').textContent=game.groups.reduce(function(n,g){return Math.max(n,g.ids.length);},0);$('total').textContent=level.regions.length;
|
||||
$('game-title').textContent=level.name+(mode==='city'?' · 地级拼图':'');$('game-speed').textContent=config.name+' · '+Engine.BOUNCES[bounce].name+' · ×'+config.multiplier;
|
||||
$('game-title').textContent=displayLevelName()+(selectedProvince?' · '+detailLabel():'');$('game-speed').textContent=game.snap.name+' · '+game.bounce.name+' · 随心落子';
|
||||
$('progress-label').textContent='已落下 '+game.dropped+' / '+level.regions.length+' 块'+(game.maxChain>1?' · 最佳 '+game.maxChain+' 连合':'');
|
||||
}
|
||||
function polygon(context,ring,transform){context.beginPath();ring.forEach(function(p,i){var q=transform(p);if(i===0)context.moveTo(q.x,q.y);else context.lineTo(q.x,q.y);});context.closePath();}
|
||||
function drawPiece(context,region,id,transform,opts){opts=opts||{};context.save();context.fillStyle=opts.ghost?'rgba(110,142,83,.10)':colors[id%colors.length];context.strokeStyle=opts.impact?'#96a47e':opts.physical?'#819778':'#fffdf2';context.lineWidth=opts.impact?1.65:opts.physical?1.4:1.25;if(opts.physical){context.shadowColor='rgba(42,62,46,.15)';context.shadowOffsetY=2;context.shadowBlur=3;}region.rings.forEach(function(r){polygon(context,r,transform);context.fill();context.stroke();});if(opts.label){context.shadowColor='transparent';context.shadowOffsetY=0;context.shadowBlur=0;var p=transform(region.center);var name=mode==='province'?shortName(region.name):region.name;var size=opts.font||10;context.font='500 '+size+'px -apple-system,"PingFang SC",sans-serif';context.textAlign='center';context.textBaseline='middle';context.strokeStyle='rgba(255,254,244,.88)';context.lineWidth=2.5;context.strokeText(name,p.x,p.y);context.fillStyle='#486044';context.fillText(name,p.x,p.y);}context.restore();}
|
||||
function polygon(context,ring,transform,append){if(!append)context.beginPath();ring.forEach(function(p,i){var q=transform(p);if(i===0)context.moveTo(q.x,q.y);else context.lineTo(q.x,q.y);});context.closePath();}
|
||||
function drawPiece(context,region,id,transform,opts){opts=opts||{};context.save();context.fillStyle=opts.ghost?'rgba(110,142,83,.10)':colors[id%colors.length];context.strokeStyle=opts.celebrating?'#ba9b53':opts.impact?'#96a47e':opts.physical?'#819778':'#fffdf2';context.lineWidth=opts.celebrating?2:opts.impact?1.65:opts.physical?1.4:1.25;if(opts.physical){context.shadowColor='rgba(42,62,46,.15)';context.shadowOffsetY=2;context.shadowBlur=3;}if(opts.celebrating){context.shadowColor='rgba(196,163,81,.5)';context.shadowBlur=8;}if(region.holes&®ion.holes.length){context.beginPath();region.rings.concat(region.holes).forEach(function(r){polygon(context,r,transform,true);});context.fill('evenodd');context.stroke();}else region.rings.forEach(function(r){polygon(context,r,transform);context.fill();context.stroke();});if(opts.label){context.shadowColor='transparent';context.shadowOffsetY=0;context.shadowBlur=0;var p=transform(region.center);var name=region.labelName||(mode==='province'?shortName(region.name):region.name);var size=opts.font||10;context.font='500 '+size+'px -apple-system,"PingFang SC",sans-serif';context.textAlign='center';context.textBaseline='middle';context.strokeStyle='rgba(255,254,244,.88)';context.lineWidth=2.5;context.strokeText(name,p.x,p.y);context.fillStyle='#486044';context.fillText(name,p.x,p.y);}context.restore();}
|
||||
function staticTransform(ww,hh,padding){var sc=Math.min((ww-padding*2)/level.width,(hh-padding*2)/level.height),ox=(ww-level.width*sc)/2,oy=(hh-level.height*sc)/2;return function(p){return {x:p[0]*sc+ox,y:p[1]*sc+oy};};}
|
||||
function drawDecorations(context,transform){if(!level.decorations)return;context.save();context.fillStyle='#a1ad95';context.strokeStyle='#a1ad95';context.lineWidth=.5;level.decorations.forEach(function(ring){polygon(context,ring,transform);context.fill();context.stroke();});context.restore();}
|
||||
function drawDecorations(context,transform,map){map=map||level;if(!map.decorations)return;context.save();context.fillStyle='#a1ad95';context.strokeStyle='#a1ad95';context.lineWidth=.5;map.decorations.forEach(function(ring){polygon(context,ring,transform);context.fill();context.stroke();});context.restore();}
|
||||
function renderLabels(context,items,ww,hh,fontSize,obstacles){
|
||||
var placed=[],offsets=[[0,0],[0,-18],[0,18],[-24,-12],[24,-12],[-24,12],[24,12],[0,-36],[0,36],[-45,0],[45,0],[-45,-25],[45,-25]];
|
||||
context.save();context.font='500 '+fontSize+'px -apple-system,"PingFang SC",sans-serif';context.textAlign='center';context.textBaseline='middle';
|
||||
items.sort(function(a,b){return Number(a.tiny)-Number(b.tiny);}).forEach(function(item){
|
||||
var lines=item.name.length>7?[item.name.slice(0,Math.ceil(item.name.length/2)),item.name.slice(Math.ceil(item.name.length/2))]:[item.name];
|
||||
var width=Math.max.apply(null,lines.map(function(line){return context.measureText(line).width;}))+10,height=lines.length*(fontSize+2)+5,best=null,bestCost=Infinity;
|
||||
offsets.forEach(function(o){var x=Math.max(width/2+4,Math.min(ww-width/2-4,item.anchor.x+o[0])),y=Math.max(height/2+4,Math.min(hh-height/2-5,item.anchor.y+o[1]));var box={x:x-width/2,y:y-height/2,w:width,h:height,cx:x,cy:y};var cost=Math.hypot(x-item.anchor.x,y-item.anchor.y)*.25;placed.forEach(function(p){var overlap=Math.max(0,Math.min(p.x+p.w,box.x+box.w)-Math.max(p.x,box.x))*Math.max(0,Math.min(p.y+p.h,box.y+box.h)-Math.max(p.y,box.y));cost+=overlap*15;});(obstacles||[]).forEach(function(p){cost+=Math.max(0,Math.min(p.x+p.w,box.x+box.w)-Math.max(p.x,box.x))*Math.max(0,Math.min(p.y+p.h,box.y+box.h)-Math.max(p.y,box.y))*25;});if(cost<bestCost){bestCost=cost;best=box;}});
|
||||
function consider(o){var x=Math.max(width/2+4,Math.min(ww-width/2-4,item.anchor.x+o[0])),y=Math.max(height/2+4,Math.min(hh-height/2-5,item.anchor.y+o[1]));var box={x:x-width/2,y:y-height/2,w:width,h:height,cx:x,cy:y};var cost=Math.hypot(x-item.anchor.x,y-item.anchor.y)*.25;placed.forEach(function(p){var overlap=Math.max(0,Math.min(p.x+p.w,box.x+box.w)-Math.max(p.x,box.x))*Math.max(0,Math.min(p.y+p.h,box.y+box.h)-Math.max(p.y,box.y));cost+=overlap>.01?100000+overlap*15:0;});(obstacles||[]).forEach(function(p){var covered=Math.max(0,Math.min(p.x+p.w,box.x+box.w)-Math.max(p.x,box.x))*Math.max(0,Math.min(p.y+p.h,box.y+box.h)-Math.max(p.y,box.y));cost+=covered>.01?100000+covered*25:0;});if(cost<bestCost){bestCost=cost;best=box;}}
|
||||
offsets.forEach(consider);
|
||||
function blocked(box){return placed.concat(obstacles||[]).some(function(p){return Math.min(p.x+p.w,box.x+box.w)>Math.max(p.x,box.x)+.1&&Math.min(p.y+p.h,box.y+box.h)>Math.max(p.y,box.y)+.1;});}
|
||||
// Crowded clusters need farther label positions before accepting an overlap.
|
||||
for(var radius=48;blocked(best)&&radius<Math.max(ww,hh);radius+=24){for(var direction=0;direction<16;direction++){var angle=direction*Math.PI/8;consider([Math.cos(angle)*radius,Math.sin(angle)*radius]);}}
|
||||
var detached=item.tiny||Math.hypot(best.cx-item.anchor.x,best.cy-item.anchor.y)>10;
|
||||
if(detached){context.strokeStyle='rgba(105,133,86,.65)';context.lineWidth=.8;context.beginPath();context.moveTo(Math.max(1,Math.min(ww-1,item.anchor.x)),Math.max(1,Math.min(hh-1,item.anchor.y)));context.lineTo(best.cx,best.cy);context.stroke();context.fillStyle='rgba(255,254,245,.94)';context.fillRect(best.x,best.y,best.w,best.h);context.strokeStyle='rgba(170,187,149,.7)';context.strokeRect(best.x+.5,best.y+.5,best.w-1,best.h-1);}
|
||||
context.lineWidth=2.5;context.strokeStyle='rgba(255,254,244,.95)';context.fillStyle='#486044';lines.forEach(function(line,index){var y=best.cy+(index-(lines.length-1)/2)*(fontSize+2);context.strokeText(line,best.cx,y);context.fillText(line,best.cx,y);});best.name=item.name;placed.push(best);
|
||||
context.lineWidth=fontSize*.2;context.strokeStyle='rgba(255,254,244,.95)';context.fillStyle='#486044';lines.forEach(function(line,index){var y=best.cy+(index-(lines.length-1)/2)*(fontSize+2);context.strokeText(line,best.cx,y);context.fillText(line,best.cx,y);});best.name=item.name;best.id=item.id;placed.push(best);
|
||||
});context.restore();return placed;
|
||||
}
|
||||
function drawReference(){var c=$('reference'),ww=c.clientWidth||500,hh=c.clientHeight||240,con=sizeCanvas(c,ww,hh);con.clearRect(0,0,ww,hh);var trans=staticTransform(ww,hh-18,8);level.regions.forEach(function(r,i){drawPiece(con,r,i,trans,{label:false});});drawDecorations(con,trans);if(level.id==='100000'&&hh>110){var important=level.regions.filter(function(r){return ['710000','810000','820000'].indexOf(r.id)>=0;}).map(function(r){return {name:shortName(r.name),anchor:trans(r.label||r.center),tiny:true};});renderLabels(con,important,ww,hh-18,8);}}
|
||||
function drawNext(){var c=$('next'),ww=c.clientWidth||64,hh=c.clientHeight||46,con=sizeCanvas(c,ww,hh);con.clearRect(0,0,ww,hh);if(!game||!game.queue.length){$('next-name').textContent=game?'已全部落下':'—';return;}var id=game.queue[0],r=level.regions[id],ring=r.rings[0],xs=ring.map(function(p){return p[0];}),ys=ring.map(function(p){return p[1];}),minx=Math.min.apply(null,xs),maxx=Math.max.apply(null,xs),miny=Math.min.apply(null,ys),maxy=Math.max.apply(null,ys),sc=Math.min((ww-8)/(maxx-minx),(hh-6)/(maxy-miny));drawPiece(con,{rings:[ring],center:r.center},id,function(p){return {x:(p[0]-(minx+maxx)/2)*sc+ww/2,y:(p[1]-(miny+maxy)/2)*sc+hh/2};},{});$('next-name').textContent=shortName(r.name);}
|
||||
function contactTransform(g){
|
||||
function svgElement(tag,attrs,text){var el=document.createElementNS('http://www.w3.org/2000/svg',tag);Object.keys(attrs||{}).forEach(function(k){el.setAttribute(k,attrs[k]);});if(text!==undefined)el.textContent=text;return el;}
|
||||
function ringPath(rings){return rings.map(function(ring){return ring.map(function(p,i){return (i?'L':'M')+p[0]+','+p[1];}).join('')+'Z';}).join('');}
|
||||
function drawReference(){
|
||||
var svg=$('reference');svg.setAttribute('preserveAspectRatio',window.innerWidth<=720?'xMaxYMin meet':'xMidYMid meet');if(renderedMap===entryMap)return;renderedMap=entryMap;
|
||||
var map=entryMap,national=!selectedProvince,pad=Math.max(map.width,map.height)*.055,fragment=document.createDocumentFragment();
|
||||
svg.setAttribute('viewBox',[-pad,-pad,map.width+pad*2,map.height+pad*2].join(' '));svg.setAttribute('aria-label',national?'中国地图,点击省区选择旅程':shortName(map.name)+'地图');
|
||||
var countyLabels=null;if(selectedCity){var measure=document.createElement('canvas').getContext('2d'),fontSize=Math.max(map.width,map.height)*.027;countyLabels=renderLabels(measure,map.regions.map(function(r){var anchor=r.label||r.center;return {id:r.id,name:r.labelName||r.name,anchor:{x:anchor[0]+pad,y:anchor[1]+pad},tiny:false};}),map.width+pad*2,map.height+pad*2,fontSize,[]);}
|
||||
var callouts={'110000':[-55,-58],'120000':[50,-8],'310000':[80,0],'810000':[100,44],'820000':[-30,80],'710000':[85,8]};
|
||||
map.regions.forEach(function(r,i){
|
||||
var cityTarget=!national&&!selectedCity&&mode==='city'&&r.unitType==='prefecture',attributes=national?{'class':'atlas-region','data-province':r.id,'role':'button','tabindex':'0','aria-label':shortName(r.name)}:{'class':'atlas-region-detail','data-region':r.id};if(cityTarget){attributes['class']+=' atlas-city';attributes['data-city']=r.id;attributes.role='button';attributes.tabindex='0';attributes['aria-label']=shortName(r.name)+(Atlas.meta(r.id)?' · 县区拼图':' · 无县区子级');}var group=svgElement('g',attributes);
|
||||
group.appendChild(svgElement('path',{d:ringPath(r.rings.concat(r.holes||[])),'fill-rule':'evenodd',fill:colors[i%colors.length],'class':'atlas-land'}));group.appendChild(svgElement('title',{},national?shortName(r.name):r.name));
|
||||
var anchor=r.label||r.center,offset=national&&callouts[r.id],x=anchor[0]+(offset?offset[0]:0),y=anchor[1]+(offset?offset[1]:0),font=Math.max(map.width,map.height)*(national?.025:.027),name=national?shortName(r.name):(r.labelName||r.name);
|
||||
if(offset){group.appendChild(svgElement('path',{d:'M'+anchor[0]+','+anchor[1]+'L'+x+','+y,'class':'atlas-leader'}));group.appendChild(svgElement('rect',{x:x-name.length*font*.54-font*.25,y:y-font*.78,width:name.length*font*1.08+font*.5,height:font*1.5,rx:font*.18,'class':'atlas-label-bg'}));}
|
||||
if(!selectedCity)group.appendChild(svgElement('text',{x:x,y:y,'font-size':font,'class':'atlas-label','text-anchor':'middle','dominant-baseline':'central'},name));fragment.appendChild(group);
|
||||
});
|
||||
if(countyLabels)countyLabels.forEach(function(box){var r=map.regions.find(function(r){return r.id===box.id;}),anchor=r.label||r.center,x=box.cx-pad,y=box.cy-pad,group=svgElement('g',{'class':'county-map-label','aria-hidden':'true'}),font=Math.max(map.width,map.height)*.027;if(Math.hypot(x-anchor[0],y-anchor[1])>10){group.appendChild(svgElement('path',{d:'M'+anchor[0]+','+anchor[1]+'L'+x+','+y,'class':'atlas-leader'}));group.appendChild(svgElement('rect',{x:box.x-pad,y:box.y-pad,width:box.w,height:box.h,rx:4,'class':'atlas-label-bg'}));}var lines=box.name.length>7?[box.name.slice(0,Math.ceil(box.name.length/2)),box.name.slice(Math.ceil(box.name.length/2))]:[box.name];lines.forEach(function(line,i){group.appendChild(svgElement('text',{x:x,y:y+(i-(lines.length-1)/2)*(font+2),'font-size':font,'class':'atlas-label','text-anchor':'middle','dominant-baseline':'central'},line));});fragment.appendChild(group);});
|
||||
if(map.mapInsets)map.mapInsets.forEach(function(inset){var font=Math.max(map.width,map.height)*.021;fragment.appendChild(svgElement('rect',{x:inset.x,y:inset.y,width:inset.width,height:inset.height,fill:'none',stroke:'#a4b295','stroke-width':Math.max(map.width,map.height)*.0012,'stroke-dasharray':'5 5',rx:6,'aria-hidden':'true'}));fragment.appendChild(svgElement('text',{x:inset.x+inset.width/2,y:inset.y-font*.55,'font-size':font,fill:'#798e70','text-anchor':'middle','class':'atlas-inset-caption'},inset.label));});
|
||||
if(map.decorations)fragment.appendChild(svgElement('path',{d:ringPath(map.decorations),'class':'atlas-maritime','aria-hidden':'true'}));
|
||||
// Small-region callouts must remain above neighboring province fills.
|
||||
if(national)Object.keys(callouts).forEach(function(code){var marker=fragment.querySelector('[data-province="'+code+'"]');if(marker)fragment.appendChild(marker);});
|
||||
while(svg.firstChild)svg.removeChild(svg.firstChild);svg.appendChild(fragment);
|
||||
if(motionAllowed()){svg.classList.remove('map-arrive');void svg.getBoundingClientRect();svg.classList.add('map-arrive');}
|
||||
}
|
||||
function drawNext(){var c=$('next'),ww=c.clientWidth||64,hh=c.clientHeight||46,con=sizeCanvas(c,ww,hh);con.clearRect(0,0,ww,hh);if(!game||!game.pending){$('next-name').textContent=game?(game.endReason?'旅程结束':'山河已到齐'):'—';return;}var id=game.pending?game.pending.id:game.queue[0],r=level.regions[id],ring=r.rings[0],xs=ring.map(function(p){return p[0];}),ys=ring.map(function(p){return p[1];}),minx=Math.min.apply(null,xs),maxx=Math.max.apply(null,xs),miny=Math.min.apply(null,ys),maxy=Math.max.apply(null,ys),sc=Math.min((ww-8)/(maxx-minx),(hh-6)/(maxy-miny))*Engine.PIECE_SIZE*game.singleFactor(id);drawPiece(con,{rings:[ring],holes:r.holes,center:r.center},id,function(p){return {x:(p[0]-(minx+maxx)/2)*sc+ww/2,y:(p[1]-(miny+maxy)/2)*sc+hh/2};},{});$('next-name').textContent=r.labelName||(mode==='province'?shortName(r.name):r.name);}
|
||||
function contactTransform(game,g){
|
||||
var left=g.hitUntil-game.clock,active=left>0&&motionAllowed(),progress=1-Math.max(0,left)/Engine.CONTACT_MS;
|
||||
var wave=active?Math.sin(progress*Math.PI*2)*Math.pow(1-progress,2)*.025*g.hitStrength:0,vertical=Math.abs(g.hitNormal.y)>=Math.abs(g.hitNormal.x);
|
||||
var sx=1+wave*(vertical?.55:-1),sy=1+wave*(vertical?-1:.55),center=g.body.position;
|
||||
return function(p){var q=game.worldPoint(g,p);return {x:center.x+(q.x-center.x)*sx,y:center.y+(q.y-center.y)*sy};};
|
||||
}
|
||||
function draw(){if(!game)return;ctx.clearRect(0,0,w,h);ctx.fillStyle='#f1f2e9';ctx.fillRect(0,0,w,h);ctx.fillStyle='#d8decd';for(var x=15;x<w;x+=22)for(var y=16;y<h;y+=22){ctx.beginPath();ctx.arc(x,y,.65,0,Math.PI*2);ctx.fill();}ctx.strokeStyle=game.overflow>0?'#bd3e42':'#d46e70';ctx.setLineDash([4,5]);ctx.beginPath();ctx.moveTo(0,Engine.DANGER_Y);ctx.lineTo(w,Engine.DANGER_Y);ctx.stroke();ctx.setLineDash([]);
|
||||
var labels=[];game.groups.forEach(function(g){var trans=contactTransform(g);g.ids.forEach(function(i){var r=level.regions[i];drawPiece(ctx,r,i,trans,{label:false,physical:true,impact:motionAllowed()&&g.hitUntil>game.clock});var points=r.rings[0].map(trans),xs=points.map(function(p){return p.x;}),ys=points.map(function(p){return p.y;}),rw=Math.max.apply(null,xs)-Math.min.apply(null,xs),rh=Math.max.apply(null,ys)-Math.min.apply(null,ys);labels.push({name:mode==='province'?shortName(r.name):r.name,anchor:trans(r.label||r.center),tiny:rw<32||rh<25});});if(g.ids.length===level.regions.length)drawDecorations(ctx,trans);});
|
||||
function drawPocketHints(game,c,level){
|
||||
var hints=game.pocketHints?game.pocketHints():[];c.save();c.fillStyle='rgba(119,153,90,.10)';c.strokeStyle='rgba(106,138,79,.55)';c.lineWidth=1;c.setLineDash([3,4]);
|
||||
hints.forEach(function(hint){hint.ids.forEach(function(id){var r=level.regions[id];c.beginPath();r.rings.concat(r.holes||[]).forEach(function(ring){polygon(c,ring,function(p){return game.worldPoint(hint.anchor,p);},true);});c.fill('evenodd');c.stroke();});});c.restore();
|
||||
}
|
||||
function drawPocketTransfers(game,c,level,transfers,clock){
|
||||
transfers.forEach(function(t){var target=game.groups.find(function(g){return t.ids.every(function(id){return g.ids.indexOf(id)>=0;});});if(!target)return;var progress=Math.min(1,(clock-t.started)/t.duration),eased=1-Math.pow(1-progress,3),from=t.from;c.save();c.globalAlpha=(1-progress)*.75;
|
||||
t.ids.forEach(function(id){drawPiece(c,level.regions[id],id,function(p){var offset=C.rotate({x:(p[0]*from.scale-from.cx)*from.factor,y:(p[1]*from.scale-from.cy)*from.factor},from.angle),end=game.worldPoint(target,p);return {x:(from.x+offset.x)*(1-eased)+end.x*eased,y:(from.y+offset.y)*(1-eased)+end.y*eased};},{physical:true});});c.restore();
|
||||
});
|
||||
}
|
||||
function draw(){if(game)drawBoard(game,ctx,{width:w,height:h,level:level,mode:mode,trace:swipeTrace,obstacles:labelObstacles,effectClock:game.clock+victoryAge});}
|
||||
function drawBoard(game,ctx,view){var w=view.width,h=view.height,level=view.level,mode=view.mode,swipeTrace=view.trace,labelObstacles=view.obstacles,effectClock=view.effectClock===undefined?game.clock:view.effectClock,celebration=activeCelebration(game.celebrations,effectClock);ctx.clearRect(0,0,w,h);ctx.fillStyle='#f1f2e9';ctx.fillRect(0,0,w,h);ctx.fillStyle='#e9eee1';ctx.fillRect(0,0,w,game.dangerY);ctx.fillStyle='#d8decd';for(var x=15;x<w;x+=22)for(var y=16;y<h;y+=22){ctx.beginPath();ctx.arc(x,y,.65,0,Math.PI*2);ctx.fill();}ctx.strokeStyle=game.overflow>0?'#bd3e42':'#d46e70';ctx.lineWidth=1;ctx.setLineDash([4,5]);ctx.beginPath();ctx.moveTo(0,game.dangerY);ctx.lineTo(w,game.dangerY);ctx.stroke();ctx.setLineDash([]);
|
||||
var transfers=motionAllowed()?(game.pocketTransfers||[]).filter(function(t){return effectClock>=t.started&&effectClock<t.started+t.duration;}):[];
|
||||
drawPocketHints(game,ctx,level);
|
||||
var labels=[];game.groups.forEach(function(g){var trans=contactTransform(game,g);g.ids.forEach(function(i){var r=level.regions[i],transfer=transfers.find(function(t){return t.ids.indexOf(i)>=0;});ctx.save();if(transfer)ctx.globalAlpha=Math.min(1,(effectClock-transfer.started)/transfer.duration*1.6);drawPiece(ctx,r,i,trans,{label:false,physical:true,impact:motionAllowed()&&g.hitUntil>game.clock,celebrating:!!celebration&&celebration.ids.indexOf(i)>=0});ctx.restore();var points=r.rings[0].map(trans),xs=points.map(function(p){return p.x;}),ys=points.map(function(p){return p.y;}),rw=Math.max.apply(null,xs)-Math.min.apply(null,xs),rh=Math.max.apply(null,ys)-Math.min.apply(null,ys);labels.push({name:r.labelName||(mode==='province'?shortName(r.name):r.name),anchor:trans(r.label||r.center),tiny:rw<32||rh<25});});if(g.ids.length===level.regions.length)drawDecorations(ctx,trans,level);});
|
||||
drawPocketTransfers(game,ctx,level,transfers,effectClock);
|
||||
var pending=game.pendingView();
|
||||
if(pending){var previewRegion=level.regions[pending.id];ctx.save();drawPiece(ctx,previewRegion,pending.id,function(p){return {x:p[0]*pending.scale+pending.tx,y:p[1]*pending.scale+pending.ty};},{physical:true});ctx.strokeStyle=pending.held?'#9f8255':'#a6b497';ctx.lineWidth=1;ctx.setLineDash([2,4]);ctx.beginPath();ctx.moveTo(pending.x,pending.bottom+3);ctx.lineTo(pending.x,game.dangerY-25);ctx.stroke();ctx.setLineDash([]);ctx.restore();}
|
||||
if(!view.replay){$('drop-countdown').style.top=Math.round(game.dangerY-23)+'px';$('drop-countdown').textContent=pending?'∞ · 左右调整,松手落下':(game.endReason?'本局已结束':'山河已到齐,继续拼合');
|
||||
document.querySelector('.danger-label').style.top=Math.round(game.dangerY-10)+'px';}
|
||||
if(swipeTrace&&swipeTrace.until>game.clock&&motionAllowed()){var life=(swipeTrace.until-game.clock)/280;ctx.save();ctx.globalAlpha=life*.30;ctx.strokeStyle='#9b8b62';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(swipeTrace.x,swipeTrace.y,9+(1-life)*12,0,Math.PI*2);ctx.moveTo(swipeTrace.x,swipeTrace.y);var length=Math.max(1,Math.hypot(swipeTrace.dx,swipeTrace.dy));ctx.lineTo(swipeTrace.x+swipeTrace.dx/length*19,swipeTrace.y+swipeTrace.dy/length*19);ctx.stroke();ctx.restore();}
|
||||
lastLabelBoxes=renderLabels(ctx,labels,w,h,Math.max(9,Math.min(12,12*Math.sqrt(game.zoom))),labelObstacles);
|
||||
var drawnLabels=renderLabels(ctx,labels,w,h,Math.max(8,Math.min(12,12*Math.sqrt(game.zoom))*Engine.PIECE_SIZE),labelObstacles);if(!view.replay)lastLabelBoxes=drawnLabels;
|
||||
if(motionAllowed())game.impacts.forEach(function(f){var life=Math.max(0,(f.until-game.clock)/Engine.SPARK_MS),radius=1.5+(1-life)*(2+f.strength*2);ctx.save();ctx.globalAlpha=life*.38;ctx.strokeStyle='#b8a779';ctx.lineWidth=1;ctx.beginPath();ctx.arc(f.x,f.y,radius,0,Math.PI*2);ctx.stroke();for(var i=0;i<2;i++){var angle=i*Math.PI+.7;ctx.beginPath();ctx.moveTo(f.x+Math.cos(angle)*(radius+1),f.y+Math.sin(angle)*(radius+1));ctx.lineTo(f.x+Math.cos(angle)*(radius+3),f.y+Math.sin(angle)*(radius+3));ctx.stroke();}ctx.restore();});
|
||||
game.fx.forEach(function(f){ctx.save();ctx.globalAlpha=Math.min(1,(f.until-game.clock)/500);ctx.textAlign='center';ctx.font='500 12px sans-serif';ctx.fillStyle='#557642';ctx.fillText(f.text,Math.max(70,Math.min(w-70,f.x)),f.y-28-(1600-f.until+game.clock)*.018);ctx.restore();});
|
||||
if(celebration)drawCelebration(ctx,celebration,effectClock,w,h,game.dangerY);
|
||||
}
|
||||
function activeCelebration(effects,clock){var active=null;(effects||[]).forEach(function(effect){if(clock>=effect.started&&clock<effect.started+effect.duration)active=effect;});return active;}
|
||||
function celebrationBox(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.lineTo(x+w-r,y);c.quadraticCurveTo(x+w,y,x+w,y+r);c.lineTo(x+w,y+h-r);c.quadraticCurveTo(x+w,y+h,x+w-r,y+h);c.lineTo(x+r,y+h);c.quadraticCurveTo(x,y+h,x,y+h-r);c.lineTo(x,y+r);c.quadraticCurveTo(x,y,x+r,y);c.closePath();}
|
||||
function celebrationText(c,text,x,y,max,size,color){c.fillStyle=color;c.textAlign='center';c.textBaseline='middle';c.font='500 '+size+'px -apple-system,"PingFang SC",sans-serif';while(size>10&&c.measureText(text).width>max){size--;c.font='500 '+size+'px -apple-system,"PingFang SC",sans-serif';}c.fillText(text,x,y);}
|
||||
function drawCelebration(c,effect,clock,w,h,line){
|
||||
var age=clock-effect.started,t=age/effect.duration,victory=effect.kind==='victory',motion=motionAllowed(),alpha=motion?Math.min(1,age/180,(effect.duration-age)/350):1;
|
||||
var cw=Math.min(w-24,victory?300:280),ch=victory?108:88,cx=w/2,cy=Math.min(h-ch/2-12,line+(h-line)*.30),lift=motion?(1-Math.min(1,age/220))*10:0;
|
||||
c.save();c.globalAlpha=Math.max(0,alpha);
|
||||
if(victory){c.fillStyle='rgba(214,187,112,.10)';c.fillRect(0,0,w,h);}
|
||||
if(motion){var count=victory?32:12;for(var i=0;i<count;i++){var phase=i*2.39996,spread=.9+(i%4)*.08,x=cx+Math.cos(phase)*(cw/2+12+38*t)*spread,y=cy+Math.sin(phase)*(ch/2+18+48*t)*spread+t*t*(victory?45:20);c.save();c.translate(x,y);c.rotate(phase+t*3);c.fillStyle=i%3===0?'#d9be78':i%3===1?'#93ad7e':'#c7a684';c.globalAlpha=alpha*(1-t)*.8;c.fillRect(-2,-3,4,i%2?7:4);c.restore();}}
|
||||
c.translate(0,-lift);c.shadowColor='rgba(48,72,52,.15)';c.shadowBlur=12;c.shadowOffsetY=5;c.fillStyle='rgba(48,80,64,.96)';celebrationBox(c,cx-cw/2,cy-ch/2,cw,ch,16);c.fill();c.shadowColor='transparent';c.strokeStyle='rgba(224,197,126,.7)';c.lineWidth=1;celebrationBox(c,cx-cw/2+4,cy-ch/2+4,cw-8,ch-8,12);c.stroke();
|
||||
celebrationText(c,victory?'山河,完整了':effect.names.join(' · ')+(effect.kind==='region'?' · 拼合完成':''),cx,cy-(victory?12:9),cw-32,victory?26:21,'#fffcee');
|
||||
var detail=victory?shortName(effect.names[0])+' · '+effect.total+' 块山河归一':effect.kind==='region'?effect.count+' 块山河相遇 · 继续你的旅程':effect.count+' / '+effect.total+' 块已拼成一体';
|
||||
celebrationText(c,detail,cx,cy+(victory?24:23),cw-32,12,'#e3cc8f');c.restore();
|
||||
}
|
||||
function schedule(){if(!rafId&&!document.hidden&&game)rafId=requestAnimationFrame(frame);}
|
||||
function frame(t){rafId=0;if(!game||screen!=='game')return;if(!last)last=t;game.tick(t-last);last=t;if(screen!=='game')return;$('tank').style.transform='translate('+game.shake.offset.toFixed(2)+'px,'+game.shake.y.toFixed(2)+'px)';$('tank').classList.toggle('impact',game.wallHitUntil>game.clock);draw();if(t-uiAt>120){uiAt=t;updateUI();}if(game.status==='playing')schedule();}
|
||||
function endTankGesture(){var gesture=tankGesture;tankGesture=null;$('tank').classList.remove('swiping');if(gesture&&typeof canvas.hasPointerCapture==='function'&&canvas.hasPointerCapture(gesture.id))canvas.releasePointerCapture(gesture.id);}
|
||||
function frame(t){rafId=0;if(!game||screen!=='game')return;if(!last)last=t;
|
||||
try{loopHealth.frames++;loopHealth.stage='physics';var started=loopNow();
|
||||
if(game.endReason==='won')victoryAge=Math.min(Engine.VICTORY_MS,victoryAge+Math.max(0,Math.min(100,t-last)));else game.tick(t-last);loopHealth.physicsMs=loopNow()-started;
|
||||
last=t;if(screen!=='game')return;loopHealth.stage='draw';started=loopNow();$('tank').style.transform='translate('+game.shake.offset.toFixed(2)+'px,'+game.shake.y.toFixed(2)+'px)';$('tank').classList.toggle('impact',game.wallHitUntil>game.clock);draw();loopHealth.drawMs=loopNow()-started;
|
||||
loopHealth.stage='video';captureVideoFrame(false);loopHealth.stage='ui';if(t-uiAt>120){uiAt=t;updateUI();}
|
||||
if(game.endReason==='won'&&victoryAge>=Engine.VICTORY_MS){showResult();return;}loopHealth.stage='idle';writeLoopHealth(false);
|
||||
}catch(error){pauseAfterFrameError(error);}
|
||||
finally{last=t;if(game&&screen==='game'&&(game.status==='playing'||game.endReason==='won'))schedule();}
|
||||
}
|
||||
function endTankGesture(){var gesture=tankGesture;if(gesture&&gesture.kind==='aim'&&game)game.cancelPendingDrag(gesture.serial);tankGesture=null;$('tank').classList.remove('swiping');if(gesture&&typeof canvas.hasPointerCapture==='function'&&canvas.hasPointerCapture(gesture.id))canvas.releasePointerCapture(gesture.id);}
|
||||
function stopInput(){if(game)game.groups.forEach(function(g){g.pendingHopUntil=-1;});endTankGesture();swipeTrace=null;clearInterval(holdTimer);holdTimer=null;inputDirection=0;keys.left=false;keys.right=false;}
|
||||
function refreshInput(){var direction=(keys.right?1:0)-(keys.left?1:0);if(!game||game.status!=='playing')direction=0;if(direction===inputDirection)return;clearInterval(holdTimer);holdTimer=null;inputDirection=direction;if(direction){game.nudge(direction);holdTimer=setInterval(function(){if(game&&game.status==='playing')game.nudge(direction);else stopInput();},150);}}
|
||||
// Collect a short flick, then emit exactly one impulse on release. Pointer
|
||||
@@ -120,6 +209,7 @@ function sampleTankGesture(e){
|
||||
}
|
||||
function releaseTankFlick(e){
|
||||
var g=tankGesture;if(!g||g.id!==e.pointerId)return;
|
||||
if(g.kind==='aim'){var rect=canvas.getBoundingClientRect();game.movePending((e.clientX-rect.left)*w/rect.width-g.offset,g.serial);game.releasePending(g.serial);endTankGesture();return;}
|
||||
sampleTankGesture(e);var samples=g.samples,end=samples[samples.length-1],first=samples[0],dx=end.x-first.x,dy=end.y-first.y,elapsed=end.t-first.t,distance=Math.hypot(dx,dy);
|
||||
// A slow drag or a held finger is not a flick. Pauses/cancellations discard it.
|
||||
if(game&&game.status==='playing'&&distance>=18&&elapsed<=350&&distance/Math.max(16,elapsed)>=.16){
|
||||
@@ -130,51 +220,136 @@ function releaseTankFlick(e){
|
||||
}
|
||||
canvas.addEventListener('pointerdown',function(e){
|
||||
if(screen!=='game'||!game||game.status!=='playing'||$('modal').hasAttribute('open')||tankGesture||e.isPrimary===false||(e.pointerType==='mouse'&&e.button!==0))return;
|
||||
e.preventDefault();var now=performance.now();tankGesture={id:e.pointerId,startX:e.clientX,startY:e.clientY,x:e.clientX,y:e.clientY,active:false,samples:[{x:e.clientX,y:e.clientY,t:now}]};if(typeof canvas.setPointerCapture==='function')canvas.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();var rect=canvas.getBoundingClientRect(),localX=(e.clientX-rect.left)*w/rect.width,localY=(e.clientY-rect.top)*h/rect.height;if(game.pending&&localY<game.dangerY){tankGesture={id:e.pointerId,kind:'aim',serial:game.pending.serial,offset:localX-game.pending.x};game.holdPending(tankGesture.serial);if(typeof canvas.setPointerCapture==='function')canvas.setPointerCapture(e.pointerId);return;}var now=performance.now();tankGesture={id:e.pointerId,startX:e.clientX,startY:e.clientY,x:e.clientX,y:e.clientY,active:false,samples:[{x:e.clientX,y:e.clientY,t:now}]};if(typeof canvas.setPointerCapture==='function')canvas.setPointerCapture(e.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove',function(e){if(!tankGesture||tankGesture.id!==e.pointerId)return;e.preventDefault();sampleTankGesture(e);});
|
||||
canvas.addEventListener('pointermove',function(e){if(!tankGesture||tankGesture.id!==e.pointerId)return;e.preventDefault();if(tankGesture.kind==='aim'){var rect=canvas.getBoundingClientRect();game.movePending((e.clientX-rect.left)*w/rect.width-tankGesture.offset,tankGesture.serial);return;}sampleTankGesture(e);});
|
||||
canvas.addEventListener('pointerup',releaseTankFlick);
|
||||
['pointercancel','lostpointercapture'].forEach(function(type){canvas.addEventListener(type,function(e){if(tankGesture&&tankGesture.id===e.pointerId)endTankGesture();});});
|
||||
document.addEventListener('keydown',function(e){if(screen!=='game'||$('modal').hasAttribute('open'))return;if(e.key===' '){e.preventDefault();if(!e.repeat)pause();return;}if(e.key==='ArrowUp'){e.preventDefault();if(!e.repeat&&game)game.nudgeUp();return;}if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return;e.preventDefault();if(e.repeat)return;keys[e.key==='ArrowLeft'?'left':'right']=true;refreshInput();});
|
||||
document.addEventListener('keydown',function(e){if(e.target===$('upright-once'))return;if(screen!=='game'||$('modal').hasAttribute('open'))return;if(e.key==='Enter'&&e.target===canvas&&game&&game.speed.manual&&game.pending){e.preventDefault();if(!e.repeat){var serial=game.pending.serial;game.holdPending(serial);game.releasePending(serial);}return;}if(e.key===' '){e.preventDefault();if(!e.repeat)pause();return;}if(e.key==='ArrowUp'){e.preventDefault();if(!e.repeat&&game)game.nudgeUp();return;}if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return;e.preventDefault();if(e.repeat)return;keys[e.key==='ArrowLeft'?'left':'right']=true;refreshInput();});
|
||||
document.addEventListener('keyup',function(e){if(e.key==='ArrowLeft'||e.key==='ArrowRight'){keys[e.key==='ArrowLeft'?'left':'right']=false;refreshInput();}});
|
||||
document.addEventListener('pointerdown',function(){document.body.classList.add('using-pointer');});document.addEventListener('keydown',function(e){if(e.key==='Tab')document.body.classList.remove('using-pointer');});$('start').addEventListener('click',start);$('pause').addEventListener('click',pause);
|
||||
function openProvincePicker(){
|
||||
openModal('PICK A DESTINATION','<h2>这局,拼哪里?</h2><div id="province-grid" class="province-grid" role="group" aria-label="选择省区"></div><p class="picker-note">直辖市、港澳没有地级关卡;台湾省暂缺地级轮廓数据。省级版包含全部 34 个省级行政区。</p>');$('modal').classList.add('province-picker-modal');$('province-picker').classList.add('expanded');
|
||||
data.levels.slice(1).forEach(function(l){var button=document.createElement('button');button.type='button';button.textContent=shortName(l.name);button.setAttribute('data-province',l.id);button.setAttribute('aria-pressed',String(l.id===provinceCode));button.className=l.id===provinceCode?'selected':'';button.addEventListener('click',function(){provinceCode=l.id;selectLevel();closeModal();});$('province-grid').appendChild(button);});
|
||||
function provincePreview(region){
|
||||
var xs=[],ys=[];region.rings.forEach(function(r){r.forEach(function(p){xs.push(p[0]);ys.push(p[1]);});});var minx=Math.min.apply(null,xs),miny=Math.min.apply(null,ys),width=Math.max.apply(null,xs)-minx,height=Math.max.apply(null,ys)-miny;
|
||||
var local=function(p){return [p[0]-minx,p[1]-miny];};return {id:region.id,name:region.name,width:width,height:height,regions:[{id:region.id,name:region.name,rings:region.rings.map(function(r){return r.map(local);}),center:local(region.center),label:local(region.label||region.center)}]};
|
||||
}
|
||||
function selectLevel(){level=mode==='province'?data.levels[0]:(data.levels.find(function(l){return l.id===provinceCode;})||data.levels[1]);updateUI();layout();}
|
||||
document.querySelectorAll('[data-mode]').forEach(function(b){b.addEventListener('click',function(){if(screen!=='entry'||performance.now()<suppressModeClickUntil)return;mode=b.getAttribute('data-mode');selectLevel();});});
|
||||
var modeTabs=document.querySelector('.mode-tabs');
|
||||
modeTabs.addEventListener('pointerdown',function(e){if(screen==='entry'&&e.isPrimary!==false)modeGesture={x:e.clientX,y:e.clientY,id:e.pointerId};});
|
||||
document.addEventListener('pointerup',function(e){if(!modeGesture||modeGesture.id!==e.pointerId)return;var dx=e.clientX-modeGesture.x,dy=e.clientY-modeGesture.y;modeGesture=null;if(screen==='entry'&&Math.abs(dx)>35&&Math.abs(dx)>Math.abs(dy)*1.3){mode=dx<0?'city':'province';suppressModeClickUntil=performance.now()+350;selectLevel();}});
|
||||
document.addEventListener('pointercancel',function(){modeGesture=null;});
|
||||
document.querySelectorAll('[data-speed]').forEach(function(b){b.addEventListener('click',function(){if(screen!=='entry')return;speed=Number(b.getAttribute('data-speed'));updateUI();});});document.querySelectorAll('[data-bounce]').forEach(function(b){b.addEventListener('click',function(){if(screen!=='entry')return;bounce=Number(b.getAttribute('data-bounce'));updateUI();});});$('province-picker').addEventListener('click',openProvincePicker);
|
||||
$('rules-button').addEventListener('click',function(){openModal('HOW TO PLAY','<h2>轻摇轻颠,拼一片山河</h2><ol><li><b>先选旅程,再开一局。</b>选择省级 / 地级版、地区、下落速度和弹性,点击开始。</li><li><b>晃动整个框。</b>在容器内快速轻划并松手,触发一次晃动。划过位置附近更剧烈,所有地块随后自由运动;手指只给出大致方向,不能牵引地块。慢拖或按住不触发,点击底部圆形暂停图标可查看本局、结束或重开;电脑也可使用方向键。</li><li><b>相邻的边自动吸合。</b>碎片碰撞时会轻微弹跳、翻动,停稳后自动回正;只有真实相邻地区按正确方位接触时才合并。把所有碎片拼为一个整体即可通关。</li></ol><div class="rules-score">合并基础分:100 + 40 ×(合并后块数 − 2)<br>8.5 秒内连合,每次系数 +0.25,最高 ×2。<br>速度倍率:佛系看山 ×1 / 说走就走 ×1.5 / 一路狂飙 ×2 / 山河起飞 ×3。<br>跨海 / 隔区连接 ×0.8;通关再奖 1000 × 速度倍率。<br>轻划不限次数、不扣分;弹性不改变得分倍率,最佳纪录按速度和弹性分别保存。</div><p>弹性推荐“蹦蹦跳”,方便腾挪;“轻轻弹”偏沉稳,“弹力全开”需要控制节奏。稳定堆过顶部红线超过 2.4 秒,本局结束;也可随时从暂停页结束并生成成绩图。省级离岛和地级间隔分别标记为跨海、隔区连接。</p>');});
|
||||
$('data-button').addEventListener('click',function(){openModal('MAP NOTES','<h2>关于这片山河</h2><p>轮廓来源:阿里云 DataV.GeoAtlas 公开行政区数据,2026-10-16 下载,随包离线提供。轮廓已为游戏做简化,所有源数据外环及独立海域线段随包保留。</p><p>省级版包含 34 块;地级版提供 27 个省区关卡、333 个地级单元,包含地级市、自治州、地区、盟,不含县和县级市。直辖市、港澳不设地级关卡;数据源暂缺台湾省地级轮廓。</p><p>岛屿及未纳入的省直辖县造成的间隔,使用明确标记的跨海 / 隔区连接。地图仅供游戏示意,不用于测绘、导航或区划查询。</p><p>纪录仅在本地保存,存储失败不影响游玩。</p>');});
|
||||
function selectProvince(code){
|
||||
if(screen!=='entry')return;var region=data.levels[0].regions.find(function(r){return r.id===code;});if(code&&!region)return;
|
||||
mapRequest++;pendingCity=null;selectedCity=null;selectedProvince=code||null;var city=code&&Atlas.get(code);mode=city?(city.kind==='county'||city.kind==='county-city'||city.kind==='partition'?city.kind:'city'):'province';level=city||data.levels[0];entryMap=city||(region?provincePreview(region):level);updateUI();drawReference();
|
||||
}
|
||||
async function selectCity(code){
|
||||
if(screen!=='entry'||!selectedProvince||selectedCity||mode!=='city')return;var meta=Atlas.meta(code),province=selectedProvince;
|
||||
if(!meta){if(Atlas.unavailable(code))toast(shortName(Atlas.unavailable(code).name)+'下辖镇街,暂无县区拼图');return;}if(meta.province!==province)return;
|
||||
var request=++mapRequest;pendingCity=code;updateUI();
|
||||
try{var local=await Atlas.load(code);if(request!==mapRequest||screen!=='entry'||selectedProvince!==province)return;pendingCity=null;selectedCity=code;mode='county';level=local;entryMap=local;updateUI();drawReference();}
|
||||
catch(e){if(request!==mapRequest)return;pendingCity=null;updateUI();toast('县区地图展开失败,请再点一次重试');}
|
||||
}
|
||||
$('atlas-back').addEventListener('click',function(){selectProvince(selectedCity?selectedProvince:null);});
|
||||
function chooseMapTarget(event){var target=event.target.closest&&event.target.closest('[data-province],[data-city]');if(!target)return;if(event.type==='keydown'){if(event.key!=='Enter'&&event.key!==' ')return;event.preventDefault();}if(target.hasAttribute('data-province'))selectProvince(target.getAttribute('data-province'));else selectCity(target.getAttribute('data-city'));}
|
||||
$('reference').addEventListener('click',chooseMapTarget);$('reference').addEventListener('keydown',chooseMapTarget);
|
||||
function openDifficulty(starting,origin){
|
||||
starting=starting===true;if(!starting&&screen!=='entry')return;if(selectedProvince&&!detailPlayable())return;
|
||||
difficultyDraft={snap:snap,bounce:bounce,starting:starting};
|
||||
openModal(starting?'READY TO GO':'JOURNEY SETTINGS',$('difficulty-template').innerHTML,[{label:starting?'取消':'取消修改',action:dismissModal},{id:starting?'confirm-journey':'confirm-difficulty',label:starting?'确认难度,出发':'完成设置',primary:true,action:function(){var choice=difficultyDraft;if(!choice)return;bounce=choice.bounce;snap=choice.snap;closeModal(false);if(choice.starting)launchJourney();}}]);
|
||||
$('modal').classList.add('difficulty-modal');
|
||||
if(starting&&origin&&motionAllowed()){var dialog=$('modal'),box=dialog.getBoundingClientRect();dialog.style.setProperty('--launch-x',origin.left+origin.width/2-box.left-box.width/2+'px');dialog.style.setProperty('--launch-y',origin.top+origin.height/2-box.top-box.height/2+'px');dialog.style.setProperty('--launch-sx',Math.max(.06,Math.min(1,origin.width/box.width)));dialog.style.setProperty('--launch-sy',Math.max(.06,Math.min(1,origin.height/box.height)));void dialog.offsetWidth;dialog.classList.add('difficulty-arrive');}
|
||||
}
|
||||
function journalCategory(l){return l.kind==='province'?'national':l.kind==='prefecture'?'province':'detail';}
|
||||
function trophy(won,name){return '<span class="triumph'+(won?' earned':'')+'" role="img" aria-label="'+name+(won?':已通关':':未通关')+'"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h10v6a5 5 0 0 1-10 0V3ZM7 5H3v3a4 4 0 0 0 4 4m10-7h4v3a4 4 0 0 1-4 4M12 14v5m-4 2h8m-6-2h4"/></svg></span>';}
|
||||
function renderJournal(){
|
||||
var categories=[['all','全部'],['national','全国'],['province','省内'],['detail','县市区']],all=Atlas.catalog,scoped=all.filter(function(l){return !journalProvince||l.province===journalProvince;}),filtered=scoped.filter(function(l){return journalFilter==='all'||journalCategory(l)===journalFilter;}),pageSize=6,pages=Math.max(1,Math.ceil(filtered.length/pageSize)),earned=0,completed=0;
|
||||
journalPage=Math.max(0,Math.min(journalPage,pages-1));all.forEach(function(l){var stats=C.journeyStats(l.id,records,triumphs);earned+=stats.wins.filter(Boolean).length;if(stats.wins.some(Boolean))completed++;});
|
||||
var html='<h2 id="journal-title">山河战绩簿</h2><p class="journal-summary">'+all.length+' 个关卡 · 已通关 '+completed+' 关 · 奖杯 '+earned+' / '+all.length*3+'</p><div class="journal-filters" role="group" aria-label="战绩关卡分类">';
|
||||
categories.forEach(function(c){var count=scoped.filter(function(l){return c[0]==='all'||journalCategory(l)===c[0];}).length;html+='<button data-journal-filter="'+c[0]+'" aria-pressed="'+(journalFilter===c[0])+'">'+c[1]+' <small>'+count+'</small></button>';});
|
||||
html+='</div><select id="journal-province" aria-label="按省区筛选战绩"><option value="">全部省区</option>'+data.levels[0].regions.map(function(r){return '<option value="'+r.id+'"'+(journalProvince===r.id?' selected':'')+'>'+shortName(r.name)+'</option>';}).join('')+'</select><div class="journal-scroll" tabindex="0" aria-label="关卡最高分与通关奖杯">';
|
||||
filtered.slice(journalPage*pageSize,(journalPage+1)*pageSize).forEach(function(l){var stats=C.journeyStats(l.id,records,triumphs),name=l.kind==='province'?'中国':shortName(l.name),label=l.playLabel||(l.kind==='province'?'省级拼图':'地级拼图');
|
||||
html+='<article class="journal-card" data-journal-level="'+l.id+'"><div class="journal-card-top"><div><h3>'+name+'</h3><span>'+(l.provinceName?shortName(l.provinceName)+' · ':'')+label+' · '+l.count+' 块</span></div><div class="journal-best"><small>最高分</small><strong>'+(stats.best===null?'—':stats.best.toLocaleString('zh-CN'))+'</strong></div></div><div class="journal-difficulties">';
|
||||
Engine.SNAP_DIFFICULTIES.forEach(function(d,i){html+='<div>'+trophy(stats.wins[i],d.name)+'<span>'+d.name+'</span><strong>'+(stats.scores[i]===null?'未挑战':stats.scores[i].toLocaleString('zh-CN'))+'</strong></div>';});html+='</div></article>';
|
||||
});
|
||||
if(!filtered.length)html+='<p class="journal-empty">这个分类暂时没有关卡,试试其他筛选。</p>';html+='</div><div class="journal-pagination"><button data-journal-page="-1"'+(journalPage===0?' disabled':'')+' aria-label="上一页战绩">←</button><span>'+(journalPage+1)+' / '+pages+' 页</span><button data-journal-page="1"'+(journalPage>=pages-1?' disabled':'')+' aria-label="下一页战绩">→</button></div><p class="journal-note">每关每种吸附难度通关,点亮一枚奖杯;弹性不限。<br>战绩保存在本机。旧分数保留,奖杯从本次更新起记录。</p>';
|
||||
$('modal-content').innerHTML=html;
|
||||
}
|
||||
function openJournal(){if(screen!=='entry')return;journalFilter='all';journalProvince='';journalPage=0;openModal('JOURNEY RECORDS','',[]);$('modal').classList.add('journal-modal');$('modal').setAttribute('aria-labelledby','journal-title');renderJournal();}
|
||||
$('journey-records').addEventListener('click',openJournal);
|
||||
$('modal-content').addEventListener('change',function(e){if(e.target.id!=='journal-province')return;journalProvince=e.target.value;journalPage=0;renderJournal();$('journal-province').focus();});
|
||||
$('modal-content').addEventListener('click',function(e){if(!$('modal').classList.contains('journal-modal'))return;var button=e.target.closest&&e.target.closest('[data-journal-filter],[data-journal-page]');if(!button||button.disabled)return;var focusSelector;if(button.hasAttribute('data-journal-filter')){journalFilter=button.getAttribute('data-journal-filter');journalPage=0;focusSelector='[data-journal-filter="'+journalFilter+'"]';}else{journalPage+=Number(button.getAttribute('data-journal-page'));focusSelector='[data-journal-page="'+button.getAttribute('data-journal-page')+'"]';}renderJournal();var next=$('modal-content').querySelector(focusSelector);if(next&&!next.disabled)next.focus();else $('modal-content').querySelector('.journal-scroll').focus();});
|
||||
document.addEventListener('click',function(e){
|
||||
if(!difficultyDraft||!$('modal').classList.contains('difficulty-modal')||!$('modal').hasAttribute('open'))return;
|
||||
var button=e.target.closest&&e.target.closest('[data-snap],[data-bounce]');if(!button)return;
|
||||
if(button.hasAttribute('data-snap'))difficultyDraft.snap=Number(button.getAttribute('data-snap'));else difficultyDraft.bounce=Number(button.getAttribute('data-bounce'));updateUI();
|
||||
});
|
||||
function uprightNow(){if(!game||game.status!=='playing')return;var outcome=game.uprightOnce();updateUI();draw();toast(outcome.turned?'已回正 '+outcome.turned+' 组':'当前地块已经朝上',true);}
|
||||
$('upright-once').addEventListener('click',uprightNow);
|
||||
$('rules-button').addEventListener('click',function(){openModal('HOW TO PLAY','<h2>轻摇轻颠,拼一片山河</h2><ol><li><b>先选旅程,再开一局。</b>默认在全国地图直接开始省级拼图,也可点省区进入地级、县区、县市或分区拼图准备页,再点击开始。省域地图上的地级市可继续进入县区拼图,并逐级返回;每次开局先确认吸附难度和弹性。所有对局均为随心落子:在红线上方左右调整,松手才落下,没有倒计时。</li><li><b>晃动整个框。</b>在容器内快速轻划并松手,触发一次晃动。划过位置附近更剧烈,所有地块随后自由运动;手指只给出大致方向,不能牵引地块。慢拖或按住不触发,点击底部圆形暂停图标可查看本局、结束或重开;电脑也可使用方向键。</li><li><b>相邻的边自动吸合。</b>碎片随碰撞自然弹跳、翻转,默认保留当前角度;暂停键旁的“回正一次”按钮只调整当前已落下的地块,随后继续自由转动,新落下的地块不受影响。点击即将全部已落地块朝上,保持当前位置,碰撞继续由正常物理处理。真实相邻地区接近正确方位时会轻微靠拢,足够接近后自动合并,山河也会轻轻长大一点。外圈围住缺口时会显示缺失轮廓,正确地区靠近外圈且方向合适即可留洞补位,不必硬塞进封闭入口。全国版完整拼合华东、华北等地区时会亮起阶段庆祝;细分关卡在最大组合达到25%、50%、75%时提示进度。把所有碎片拼为一个整体即可通关,并欣赏山河完整的庆祝。</li></ol><div class="rules-score">合并基础分:100 + 40 ×(合并后块数 − 2)<br>8.5 秒内连合,每次系数 +0.25,最高 ×2。<br>所有对局得分倍率 ×1。<br>跨海 / 隔区连接 ×0.8;通关再奖 1000 分。<br>轻划不限次数、不扣分;吸附难度和弹性不改变得分倍率,最佳纪录按两者分别保存。</div><p>吸附推荐“轻松拼”,方向和距离更宽松;“严丝合缝”保留原有规则。弹性推荐“蹦蹦跳”,方便腾挪;“轻轻弹”偏沉稳,“弹力全开”需要控制节奏。稳定堆过顶部红线超过 2.4 秒,本局结束;也可随时从暂停页结束并生成成绩图。离岛和地区间隔分别标记为跨海、隔区连接。</p>');});
|
||||
$('data-button').addEventListener('click',function(){openModal('MAP NOTES','<h2>关于这片山河</h2><p>全国、地级与直辖市及港澳细分轮廓来源:阿里云 DataV.GeoAtlas 公开数据,2026-10-16 下载。台湾县市来自国土测绘中心开放数据 COUNTY_MOI_1090820 快照,2026-10-17 下载,采用政府资料开放授权条款第1版。所有轮廓随包离线提供,已为游戏简化并保留源外环。</p><p>新增329个地级行政区的县区关卡,共2728块,2026-10-18从DataV公开接口获取。点击省域地图的地级市即可进入,东莞、中山、儋州、嘉峪关无县区子级,暂不提供镇街细分。新增关卡轮廓按需从包内加载,仍可离线游玩。</p><p>全国34块;27个省区提供333个地级单元及30个省直辖县级地区,湖北、海南、河南、新疆均保留素材中的省直辖地区;这四关标为省域拼图。海南省内关卡的三沙诸岛采用缩放移位示意,保留全部源外环,以便看清和拼合本岛;全国地图保持原比例。北京、天津、上海各16个区,重庆38个区县。香港18个分区,澳门按源素材8个分区(堂区及路凼填海区),不将其称为县级行政区。</p><p>台湾省开放20块县市拼图,源数据离岛外环保留;部分远海岛屿采用移位示意。金门、连江属福建,不重复列入台湾关卡。此关卡为县市层级,不把市内各区与县市混在同一关。</p><p>岛屿及不相接地区之间的间隔,使用明确标记的跨海 / 隔区连接。地图仅供游戏示意,不用于测绘、导航或区划查询。</p><p>阶段完成按玩法分为华北、东北、华东、华中、华南、西南、西北七组,34块均纳入;华东含台湾,华南含香港、澳门。分组仅用于游戏阶段提示,不是新增行政区划。纪录仅在本地保存,存储失败不影响游玩。</p>');});
|
||||
function fitCard(){var box=$('result-card').parentNode,r=box.getBoundingClientRect(),sc=Math.max(.01,Math.min((r.width-22)/750,(r.height-22)/1400));$('result-card').style.width=Math.floor(750*sc)+'px';$('result-card').style.height=Math.floor(1400*sc)+'px';}
|
||||
function showResult(){
|
||||
stopInput();cancelAnimationFrame(rafId);rafId=0;draw();
|
||||
var snapshot=document.createElement('canvas');snapshot.width=canvas.width;snapshot.height=canvas.height;snapshot.getContext('2d').drawImage(canvas,0,0);
|
||||
result={reason:game.endReason||game.status,score:game.score,levelName:level.name,speed:speed,bounce:bounce,speedName:Engine.SPEEDS[speed].name,bounceName:Engine.BOUNCES[bounce].name,elapsed:game.clock,dropped:game.dropped,total:level.regions.length,largest:game.groups.reduce(function(n,g){return Math.max(n,g.ids.length);},0),maxChain:game.maxChain,history:game.history.map(function(p){return {ms:p.ms,score:p.score};})};
|
||||
result={reason:game.endReason||game.status,score:game.score,levelName:displayLevelName(),speed:4,bounce:game.bounceIndex,snap:game.snapIndex,speedName:'随心落子',snapName:game.snap.name,bounceName:game.bounce.name,uprightUsed:game.uprightUsed,uprightCount:game.uprightCount,pocketFills:game.pocketFillCount,elapsed:game.clock,dropped:game.dropped,total:level.regions.length,largest:game.groups.reduce(function(n,g){return Math.max(n,g.ids.length);},0),maxChain:game.maxChain,history:game.history.map(function(p){return {ms:p.ms,score:p.score};})};
|
||||
window.PuzzleResult.render($('result-card'),result,snapshot);snapshot.width=0;snapshot.height=0;
|
||||
var session=videoCapture;videoSettled=!session;syncVideoUI();if(session)session.finish(canvas,videoMeta()).then(function(){if(videoCapture!==session)return;videoSettled=true;syncVideoUI();}).catch(function(){if(videoCapture!==session)return;videoSettled=true;videoNotice='画面整理失败,可分享成绩图';syncVideoUI();});
|
||||
screen='result';$('game-screen').hidden=true;$('entry-screen').hidden=true;$('result-screen').hidden=false;$('result-title').textContent=window.PuzzleResult.title(result.reason);window.scrollTo(0,0);layout();
|
||||
}
|
||||
var exporting=false;
|
||||
async function exportCard(share){
|
||||
if(!result||exporting)return;var api=window.xhs&&window.xhs.miniTool,method=share?'postNote':'saveImageToPhotosAlbum';
|
||||
if(!result||exporting||videoJob)return;var api=window.xhs&&window.xhs.miniTool,method=share?'postNote':'saveImageToPhotosAlbum';
|
||||
if(!api||typeof api[method]!=='function'){toast(share?'请在小红书小工具内打开笔记发布页':'请在小红书小工具内保存成绩图');return;}
|
||||
exporting=true;$('share-note').disabled=true;$('save-card').disabled=true;
|
||||
exporting=true;syncVideoUI();
|
||||
var card=result;
|
||||
try{var path=$('result-card').toDataURL('image/png');if(typeof api.writeTempFile==='function'){var out=await api.writeTempFile({data:path});if(!out||!out.filePath)throw Error('临时文件不可用');path=out.filePath;}
|
||||
if(share){await api.postNote({title:'我的山河拼图旅程',content:'这一程,山河相遇。'+card.levelName+',得分 '+card.score+';下落速度:'+card.speedName+';弹性:'+card.bounceName+';游玩 '+window.PuzzleResult.duration(card.elapsed)+'。来晃一晃,拼出你的山河! #山河拼图',pageType:'photo_publish',mediaInfo:{image_resources:[{url:path}]}});}
|
||||
if(share){await api.postNote({title:'我的山河拼图旅程',content:'这一程,山河相遇。'+card.levelName+',得分 '+card.score+';吸附难度:'+card.snapName+';弹性:'+card.bounceName+';游玩 '+window.PuzzleResult.duration(card.elapsed)+'。来晃一晃,拼出你的山河! #山河拼图',pageType:'photo_publish',mediaInfo:{image_resources:[{url:path}]}});}
|
||||
else{await api.saveImageToPhotosAlbum({filePath:path});toast('成绩图已保存到相册');}
|
||||
}catch(e){toast(share?'分享未完成,可再次打开笔记发布页':'未能保存,请检查相册授权后重试');}
|
||||
finally{exporting=false;$('share-note').disabled=false;$('save-card').disabled=false;}
|
||||
finally{exporting=false;syncVideoUI();}
|
||||
}
|
||||
// Store compact render states, never compressed screenshots or a rolling tail.
|
||||
// Geometry and group membership are immutable and shared between samples.
|
||||
function videoScene(){
|
||||
var packed=new Float32Array(game.groups.length*10),ids=[];
|
||||
game.groups.forEach(function(g,i){var j=i*10;ids.push(g.ids);packed[j]=g.body.position.x;packed[j+1]=g.body.position.y;packed[j+2]=g.body.angle;packed[j+3]=g.factor;packed[j+4]=g.center.x;packed[j+5]=g.center.y;packed[j+6]=g.hitUntil;packed[j+7]=g.hitStrength;packed[j+8]=g.hitNormal.x;packed[j+9]=g.hitNormal.y;});
|
||||
var hints=game.pocketHints().map(function(h){return {anchor:game.groups.indexOf(h.anchor),ids:h.ids};});
|
||||
var scene={pockets:hints,transfers:game.pocketTransfers.slice(),packed:packed,ids:ids,clock:game.clock,scale:game.scale,zoom:game.zoom,dangerY:game.dangerY,overflow:game.overflow,celebrations:game.celebrations.slice(),pending:game.pendingView(),impacts:game.impacts.slice(),fx:game.fx.slice(),view:{width:w,height:h,level:level,mode:mode,trace:swipeTrace,obstacles:labelObstacles,replay:true,effectClock:game.clock+victoryAge}};
|
||||
scene.bytes=1024+packed.byteLength+ids.length*32+hints.reduce(function(n,h){return n+32+h.ids.length*8;},0)+scene.transfers.length*256+scene.impacts.length*96+scene.fx.length*160+scene.celebrations.length*128;return scene;
|
||||
}
|
||||
function renderVideoScene(context,scene){
|
||||
var groups=scene.ids.map(function(ids,i){var p=scene.packed,j=i*10;return {ids:ids,body:{position:{x:p[j],y:p[j+1]},angle:p[j+2]},factor:p[j+3],center:{x:p[j+4],y:p[j+5]},hitUntil:p[j+6],hitStrength:p[j+7],hitNormal:{x:p[j+8],y:p[j+9]}};});
|
||||
var replay={pocketTransfers:scene.transfers||[],pocketHints:function(){return (scene.pockets||[]).map(function(h){return {anchor:groups[h.anchor],ids:h.ids};});},groups:groups,clock:scene.clock,scale:scene.scale,zoom:scene.zoom,dangerY:scene.dangerY,overflow:scene.overflow,celebrations:scene.celebrations,impacts:scene.impacts,fx:scene.fx,pendingView:function(){return scene.pending;},worldPoint:Engine.Game.prototype.worldPoint};
|
||||
drawBoard(replay,context,scene.view);
|
||||
}
|
||||
function videoMeta(){var pending=game.pendingView();return {levelName:displayLevelName(),speedName:'随心落子',snapName:game.snap.name,bounceName:game.bounce.name,uprightCount:game.uprightCount,score:game.score,dropped:game.dropped,total:level.regions.length,elapsed:game.clock+(game.endReason==='won'?victoryAge:0),shakeX:game.shake.offset,shakeY:game.shake.y,dangerRatio:game.dangerY/h,countdown:pending?'∞ · 松手落下':game.endReason?'本局已结束':'山河已到齐,继续拼合'};}
|
||||
function resetVideo(){if(videoJob)videoJob.cancelled=true;videoJob=null;if(videoCapture)videoCapture.dispose();videoCapture=null;videoOutput=null;videoSettled=false;videoNotice='';}
|
||||
function syncVideoUI(){
|
||||
var button=$('video-action'),info=videoCapture&&videoCapture.info(),busy=!!videoJob;
|
||||
$('share-note').disabled=exporting||busy;$('save-card').disabled=exporting||busy;$('replay').disabled=exporting;$('result-back').disabled=exporting;
|
||||
button.disabled=exporting||(!busy&&(!videoSettled||!info||!info.supported||!info.frames));
|
||||
button.textContent=busy?'取消生成':'发视频';button.title=busy?'点击取消视频生成':'生成并发布游玩视频';
|
||||
var message=videoNotice;
|
||||
if(!message){if(!videoSettled)message='正在整理本局画面…';else if(!info||!info.supported)message='此设备暂不支持生成 MP4,可分享成绩图';else if(!info.frames)message='本局没有可用画面,可分享成绩图';else if(videoOutput)message='完整视频 · 720p / 30帧 · '+videoOutput.seconds.toFixed(1)+' 秒 · '+(videoOutput.blob.size/1024/1024).toFixed(1)+' MB · 无声';else message='完整旅程 · '+Math.max(.1,info.seconds).toFixed(1)+' 秒 · 720p / 30帧 · 无声,附成绩'+(info.sampleFPS<29?' · 长局运动采样 '+info.sampleFPS.toFixed(1)+' 帧/秒':'');}
|
||||
$('video-status').textContent=message;
|
||||
}
|
||||
async function makeVideo(){
|
||||
if(videoJob){videoJob.cancelled=true;videoNotice='正在取消…';syncVideoUI();return;}
|
||||
if(!result||exporting||!videoSettled)return;if(videoOutput){await shareVideo();return;}
|
||||
var session=videoCapture,card=result,job={cancelled:false,started:Date.now()};videoJob=job;videoNotice='正在生成视频 · 0%';syncVideoUI();
|
||||
try{var output=await window.PuzzleVideo.generate(session,$('result-card'),job,function(progress,mode){if(videoJob!==job)return;videoNotice=(mode==='realtime'?'正在合成,约需与视频等长时间 · ':'正在生成视频 · ')+Math.round(progress*100)+'%';syncVideoUI();});if(job.cancelled||videoJob!==job)return;videoOutput=output;videoNotice='';}
|
||||
catch(e){if(videoJob!==job)return;var code=e&&e.code;videoNotice=code==='background'||job.cancelReason==='background'?'切回后台已中止,可重新生成':job.cancelled||code==='cancelled'?'已取消,可重新生成':code==='size'?'整局视频超过本机安全大小限制(48 MB),未截断,可分享成绩图':code==='timeout'?'生成超时,可重试或分享成绩图':'此设备未能生成 MP4,可重试或分享成绩图';}
|
||||
finally{if(videoJob===job){videoJob=null;syncVideoUI();}}
|
||||
if(videoOutput&&videoCapture===session&&result===card&&screen==='result'&&!$('modal').hasAttribute('open'))await shareVideo();
|
||||
}
|
||||
async function shareVideo(){
|
||||
if(!result||!videoOutput||exporting||videoJob)return;var api=window.xhs&&window.xhs.miniTool;
|
||||
if(!api||typeof api.writeTempFile!=='function'||typeof api.postNote!=='function'){toast('视频已生成,请在小红书小工具内打开视频发布页');return;}
|
||||
exporting=true;syncVideoUI();var card=result,output=videoOutput,encoded=null;
|
||||
try{encoded=await window.PuzzleVideo.dataURL(output.blob);var video=await api.writeTempFile({data:encoded});encoded=null;if(!video||!video.filePath)throw Error('临时视频不可用');var cover=await api.writeTempFile({data:$('result-card').toDataURL('image/jpeg',.85)});if(!cover||!cover.filePath)throw Error('临时封面不可用');
|
||||
await api.postNote({title:'我的山河拼图回顾',content:card.levelName+',得分 '+card.score+';吸附难度:'+card.snapName+';弹性:'+card.bounceName+'。完整游玩过程,附本局成绩。 #山河拼图',pageType:'video_publish',mediaInfo:{video_resources:{video_url:video.filePath,cover_url:cover.filePath}}});
|
||||
}catch(e){toast('视频分享未完成,可再次打开发布页或分享成绩图');}
|
||||
finally{encoded=null;exporting=false;syncVideoUI();}
|
||||
}
|
||||
$('video-action').addEventListener('click',makeVideo);
|
||||
$('share-note').addEventListener('click',function(){exportCard(true);});$('save-card').addEventListener('click',function(){exportCard(false);});$('replay').addEventListener('click',start);$('result-back').addEventListener('click',showEntry);
|
||||
window.addEventListener('resize',layout);if(window.visualViewport&&typeof window.visualViewport.addEventListener==='function')window.visualViewport.addEventListener('resize',layout);window.addEventListener('blur',function(){stopInput();pause();});
|
||||
document.addEventListener('visibilitychange',function(){if(document.hidden){stopInput();pause();cancelAnimationFrame(rafId);rafId=0;}else{last=0;if(game&&game.status==='playing')schedule();}});
|
||||
layout();updateUI();storePromise=initStore();
|
||||
document.addEventListener('visibilitychange',function(){if(document.hidden){if(videoJob){videoJob.cancelReason='background';videoJob.cancelled=true;}stopInput();pause();cancelAnimationFrame(rafId);rafId=0;}else{last=0;if(game&&screen==='game'&&(game.status==='playing'||game.endReason==='won'))schedule();}});
|
||||
layout();updateUI();writeLoopHealth(true);storePromise=initStore();
|
||||
// Read-only development diagnostics: interaction is exclusively through the tank controls.
|
||||
window.shanheDebug={state:function(){return {screen:screen,status:game?game.status:'entry',score:game?game.score:0,level:level.id,speed:speed,bounce:bounce,bounceName:Engine.BOUNCES[bounce].name,history:game?game.history.map(function(p){return {ms:p.ms,score:p.score};}):[],elapsed:game?game.clock:0,result:result,shakeY:game?game.shake.y:0,dropped:game?game.dropped:0,remaining:game?game.queue.length:level.regions.length,groups:game?game.groups.length:0,shakeCount:game?game.shakeCount:0,swipeCount:game?game.swipeCount:0,swiping:!!(tankGesture&&tankGesture.active),hopCount:game?game.hopCount:0,shakeOffset:game?game.shake.offset:0,scale:game?game.scale:0,initialScale:game?game.initialScale:0,zoom:game?game.zoom:1,width:w,height:h,collisionCount:game?game.collisionCount:0,impactCount:game?game.impacts.length:0,contactPulse:game?game.groups.filter(function(g){return g.hitUntil>game.clock;}).length:0,labels:lastLabelBoxes.map(function(b){return {name:b.name,x:b.x,y:b.y,w:b.w,h:b.h};}),regions:game?game.groups.map(function(g){return {ids:g.ids.slice(),x:g.body.position.x,y:g.body.position.y,angle:g.body.angle,vx:g.body.velocity.x,vy:g.body.velocity.y};}):[]};}};
|
||||
window.shanheDebug={state:function(){return {screen:screen,entryProvince:selectedProvince,entryCity:selectedCity,mapLoading:pendingCity,entryPlayable:!selectedProvince||detailPlayable(),status:game?game.status:'entry',score:game?game.score:0,level:level.id,speed:4,uprightCount:game?game.uprightCount:0,uprightUsed:game?game.uprightUsed:false,pocketFills:game?game.pocketFillCount:0,pocketHints:game?game.pocketHints().map(function(h){return {anchor:h.anchor.ids.slice(),missing:h.ids.slice()};}):[],snap:game?game.snapIndex:snap,snapName:game?game.snap.name:Engine.SNAP_DIFFICULTIES[snap].name,bounce:bounce,bounceName:Engine.BOUNCES[bounce].name,dangerY:game?game.dangerY:0,pending:game?game.pendingView():null,aiming:!!(tankGesture&&tankGesture.kind==='aim'&&game&&game.pending&&tankGesture.serial===game.pending.serial),history:game?game.history.map(function(p){return {ms:p.ms,score:p.score};}):[],elapsed:game?game.clock:0,result:result,milestones:game?game.milestones.slice():[],celebration:game?activeCelebration(game.celebrations,game.clock+victoryAge):null,victoryAge:victoryAge,video:{capture:videoCapture?videoCapture.info():null,settled:videoSettled,generating:!!videoJob,generated:!!videoOutput,bytes:videoOutput?videoOutput.blob.size:0,seconds:videoOutput?videoOutput.seconds:0,mode:videoOutput?videoOutput.mode:null},shakeY:game?game.shake.y:0,dropped:game?game.dropped:0,remaining:game?game.queue.length:level.regions.length,groups:game?game.groups.length:0,shakeCount:game?game.shakeCount:0,swipeCount:game?game.swipeCount:0,swiping:!!(tankGesture&&tankGesture.kind!=='aim'&&tankGesture.active),hopCount:game?game.hopCount:0,shakeOffset:game?game.shake.offset:0,scale:game?game.scale:0,initialScale:game?game.initialScale:0,zoom:game?game.zoom:1,width:w,height:h,collisionCount:game?game.collisionCount:0,overflow:game?game.overflow:0,magnetPairs:game?game.magnetPairs||0:0,unsupportedWakeups:game?game.unsupportedWakeups:0,impactCount:game?game.impacts.length:0,contactPulse:game?game.groups.filter(function(g){return g.hitUntil>game.clock;}).length:0,labels:lastLabelBoxes.map(function(b){return {name:b.name,x:b.x,y:b.y,w:b.w,h:b.h};}),regions:game?game.groups.map(function(g){return {ids:g.ids.slice(),factor:g.factor,growing:!!g.growth,grounded:g.grounded,settled:!!g.settled,sleeping:g.body.isSleeping,overflow:g.overflow,x:g.body.position.x,y:g.body.position.y,angle:g.body.angle,angularVelocity:g.body.angularVelocity,vx:g.body.velocity.x,vy:g.body.velocity.y};}):[]};}};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
(function(root,factory){
|
||||
if(typeof module==='object'&&module.exports)module.exports=factory;
|
||||
else root.PuzzleAtlas=factory(root.MAP_DATA,root.COUNTY_INDEX,function(meta,done){
|
||||
var script=document.createElement('script'),finished=false,timer;
|
||||
function settle(error){if(finished)return;finished=true;clearTimeout(timer);script.onload=null;script.onerror=null;if(script.parentNode)script.parentNode.removeChild(script);done(error);}
|
||||
script.src=meta.path;script.onload=function(){settle(null);};script.onerror=function(){settle(new Error('Local map unavailable'));};
|
||||
timer=setTimeout(function(){settle(new Error('Local map timeout'));},12000);document.head.appendChild(script);
|
||||
});
|
||||
})(typeof window!=='undefined'?window:this,function(data,index,transport){
|
||||
'use strict';
|
||||
var base={},metadata={},cache={},pending={},inflightFiles={},recent=[],limit=3;
|
||||
data.levels.forEach(function(level){base[level.id]=level;});
|
||||
(index.levels||[]).forEach(function(meta){metadata[meta.id]=meta;});
|
||||
var unavailable={};(index.unavailable||[]).forEach(function(meta){unavailable[meta.id]=meta;});
|
||||
var catalog=data.levels.map(function(level){return {id:level.id,name:level.name,province:level.kind==='province'?null:(level.province||level.id),kind:level.kind,playLabel:level.playLabel,count:level.regions.length};}).concat(index.levels||[]);
|
||||
function touch(id){var at=recent.indexOf(id);if(at>=0)recent.splice(at,1);recent.push(id);while(recent.length>limit)delete cache[recent.shift()];}
|
||||
function get(id){if(base[id])return base[id];if(cache[id]){touch(id);return cache[id];}return null;}
|
||||
function register(level){var meta=level&&metadata[level.id];if(!meta||level.province!==meta.province||level.regions.length!==meta.count||!Number.isFinite(level.width)||!Number.isFinite(level.height)||level.width<=0||level.height<=0)throw Error('Invalid county map');
|
||||
var ids={};level.regions.forEach(function(r){if(!r.id||ids[r.id]||!r.rings||!r.rings.length)throw Error('Invalid county regions');ids[r.id]=true;});
|
||||
cache[level.id]=level;touch(level.id);if(pending[level.id])pending[level.id].value=level;
|
||||
}
|
||||
function loadFile(meta){if(inflightFiles[meta.path])return inflightFiles[meta.path];var promise=Promise.resolve().then(function(){return new Promise(function(resolve,reject){transport(meta,function(error){if(error)reject(error);else resolve();});});}).then(function(){delete inflightFiles[meta.path];},function(error){delete inflightFiles[meta.path];throw error;});inflightFiles[meta.path]=promise;return promise;}
|
||||
function load(id){var existing=get(id);if(existing)return Promise.resolve(existing);if(pending[id])return pending[id].promise;var meta=metadata[id];if(!meta||!/^\.\/county\/\d{6}\.js$/.test(meta.path))return Promise.reject(new Error('Unknown county stage'));
|
||||
var request={value:null,promise:null};pending[id]=request;
|
||||
request.promise=loadFile(meta).then(function(){var level=request.value||get(id);if(!level)throw new Error('County map did not register');return level;}).then(function(level){delete pending[id];return level;},function(error){delete pending[id];throw error;});return request.promise;
|
||||
}
|
||||
return {catalog:catalog,meta:function(id){return metadata[id]||null;},unavailable:function(id){return unavailable[id]||null;},get:get,load:load,register:register,cacheSize:function(){return recent.length;}};
|
||||
});
|
||||
+26
-3
@@ -3,9 +3,32 @@
|
||||
function rotate(p,a){var c=Math.cos(a),s=Math.sin(a);return {x:p.x*c-p.y*s,y:p.x*s+p.y*c};}
|
||||
function angleDelta(a,b){var d=(a-b)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d< -Math.PI)d+=Math.PI*2;return d;}
|
||||
function linked(a,b,edges){for(var i=0;i<edges.length;i++){var e=edges[i];if((a.indexOf(e[0])>=0&&b.indexOf(e[1])>=0)||(a.indexOf(e[1])>=0&&b.indexOf(e[0])>=0))return e;}return null;}
|
||||
function canSnap(a,b,edges,tolerance){var edge=linked(a.ids,b.ids,edges);if(!edge||Math.abs(angleDelta(a.angle,b.angle))>.24)return null;var d=Math.hypot(a.tx-b.tx,a.ty-b.ty);return d<=tolerance?{edge:edge,distance:d}:null;}
|
||||
function canSnap(a,b,edges,tolerance,angleTolerance,pivot,knownEdge){var edge=arguments.length>6?knownEdge:linked(a.ids,b.ids,edges);if(!edge||Math.abs(angleDelta(a.angle,b.angle))>(angleTolerance===undefined?.28:angleTolerance))return null;var dx=b.tx-a.tx,dy=b.ty-a.ty;if(pivot){var ap=rotate(pivot,a.angle),bp=rotate(pivot,b.angle);dx+=bp.x-ap.x;dy+=bp.y-ap.y;}var d=Math.hypot(dx,dy);return d<=tolerance?{edge:edge,distance:d,dx:dx,dy:dy}:null;}
|
||||
function mergePoints(size,chain,speed,sea){return Math.round((100+40*(size-2))*(1+Math.min(chain-1,4)*.25)*speed*(sea?.8:1));}
|
||||
function shuffle(a,rng){var b=a.slice();rng=rng||Math.random;for(var i=b.length-1;i>0;i--){var j=Math.floor(rng()*(i+1));var t=b[i];b[i]=b[j];b[j]=t;}return b;}
|
||||
function safeRecord(v){if(!v||typeof v!=='object'||Array.isArray(v))return {};var out={};Object.keys(v).forEach(function(k){if(/^\d+:[0-3](?::[0-2])?$/.test(k)&&typeof v[k]==='number'&&isFinite(v[k])&&v[k]>=0)out[k]=Math.floor(v[k]);});return out;}
|
||||
return {rotate:rotate,angleDelta:angleDelta,linked:linked,canSnap:canSnap,mergePoints:mergePoints,shuffle:shuffle,safeRecord:safeRecord};
|
||||
// Rasterise the source polygons once. Flooding their complement distinguishes
|
||||
// a sealed pocket from an open bay; holes and unlabelled sea remain traversable.
|
||||
function pocketAtlas(level){
|
||||
var step=Math.max(level.width,level.height)/160,w=Math.ceil(level.width/step)+6,h=Math.ceil(level.height/step)+6,labels=new Int16Array(w*h),cells=level.regions.map(function(){return [];});labels.fill(-1);
|
||||
level.regions.forEach(function(region,id){var rings=region.rings.concat(region.holes||[]);
|
||||
for(var row=0;row<h;row++){var y=(row-3+.5)*step,crossings=[];
|
||||
rings.forEach(function(ring){for(var i=0,j=ring.length-1;i<ring.length;j=i++){var a=ring[j],b=ring[i];if((a[1]>y)!==(b[1]>y))crossings.push(a[0]+(y-a[1])*(b[0]-a[0])/(b[1]-a[1]));}});
|
||||
crossings.sort(function(a,b){return a-b;});for(var k=0;k+1<crossings.length;k+=2){var left=Math.max(0,Math.ceil(crossings[k]/step+2.5)),right=Math.min(w,Math.ceil(crossings[k+1]/step+2.5));for(var x=left;x<right;x++)labels[row*w+x]=id;}
|
||||
}
|
||||
});
|
||||
for(var index=0;index<labels.length;index++)if(labels[index]>=0)cells[labels[index]].push(index);
|
||||
return {w:w,h:h,labels:labels,cells:cells};
|
||||
}
|
||||
function enclosedRegions(atlas,ids){
|
||||
var wall=new Uint8Array(atlas.cells.length),seen=new Uint8Array(atlas.labels.length),queue=new Int32Array(atlas.labels.length),head=0,tail=1;ids.forEach(function(id){wall[id]=1;});queue[0]=0;seen[0]=1;
|
||||
function visit(index){if(index<0||index>=seen.length||seen[index]||(atlas.labels[index]>=0&&wall[atlas.labels[index]]))return;seen[index]=1;queue[tail++]=index;}
|
||||
while(head<tail){var at=queue[head++],col=at%atlas.w;if(col>0)visit(at-1);if(col<atlas.w-1)visit(at+1);visit(at-atlas.w);visit(at+atlas.w);}
|
||||
var result=[];atlas.cells.forEach(function(cells,id){if(wall[id]||cells.length<3)return;var hidden=0;cells.forEach(function(index){if(!seen[index])hidden++;});if(hidden/cells.length>.85)result.push(id);});return result;
|
||||
}
|
||||
function safeRecord(v){if(!v||typeof v!=='object'||Array.isArray(v))return {};var out={};Object.keys(v).forEach(function(k){if(/^\d+:[0-4](?::[0-3])?(?::s[0-2])?$/.test(k)&&typeof v[k]==='number'&&isFinite(v[k])&&v[k]>=0)out[k]=Math.floor(v[k]);});return out;}
|
||||
function safeTriumphs(v){var out={};if(!v||typeof v!=='object'||Array.isArray(v))return out;Object.keys(v).forEach(function(k){if(/^\d+:s[0-2]$/.test(k)&&v[k]===true)out[k]=true;});return out;}
|
||||
function mergeProgress(a,b){a=a||{};b=b||{};var records=safeRecord(a.records),triumphs=safeTriumphs(a.triumphs),incoming=safeRecord(b.records);Object.keys(incoming).forEach(function(k){records[k]=Math.max(records[k]||0,incoming[k]);});Object.keys(safeTriumphs(b.triumphs)).forEach(function(k){triumphs[k]=true;});return {records:records,triumphs:triumphs};}
|
||||
function finishProgress(progress,id,snap,bounce,score,won){var out=mergeProgress(progress,{}),key=id+':4:'+bounce+':s'+snap,record={};record[key]=score;var clean=safeRecord(record);if(!Object.prototype.hasOwnProperty.call(clean,key))return out;out.records[key]=Math.max(out.records[key]||0,clean[key]);if(won===true)out.triumphs[id+':s'+snap]=true;return out;}
|
||||
function journeyStats(id,records,triumphs){var best=null,scores=[null,null,null],wins=[false,false,false],clean=safeRecord(records);Object.keys(clean).forEach(function(k){var parts=k.split(':');if(parts[0]!==id)return;var match=/:s([0-2])$/.exec(k),difficulty=match?Number(match[1]):2;best=best===null?clean[k]:Math.max(best,clean[k]);scores[difficulty]=scores[difficulty]===null?clean[k]:Math.max(scores[difficulty],clean[k]);});var trophies=safeTriumphs(triumphs);wins=wins.map(function(_,i){return trophies[id+':s'+i]===true;});return {best:best,scores:scores,wins:wins};}
|
||||
return {rotate:rotate,angleDelta:angleDelta,linked:linked,canSnap:canSnap,mergePoints:mergePoints,shuffle:shuffle,safeRecord:safeRecord,safeTriumphs:safeTriumphs,mergeProgress:mergeProgress,finishProgress:finishProgress,journeyStats:journeyStats,pocketAtlas:pocketAtlas,enclosedRegions:enclosedRegions};
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
window.PuzzleAtlas.register({"id":"150300","name":"乌海市","province":"150000","width":463.95,"height":1000,"edges":[[0,1,0],[0,2,0],[1,2,0]],"kind":"county","parent":"150000","provinceName":"内蒙古自治区","playLabel":"县区拼图","unitCaption":"市辖区 · 县 · 县级市","scopeNote":"左右调整待落块,松手落下,拼完整片乌海市。","regions":[{"id":"150302","name":"海勃湾区","rings":[[[299.6,465.5],[296.9,427.6],[290.9,414.3],[289.2,400],[293.2,385.6],[274.4,322.3],[264.9,315.8],[258,302.4],[256.1,287.7],[244.3,279.9],[242.8,271.4],[245,269.6],[257.3,274.7],[264.9,266.1],[250.7,257.4],[254.2,249.3],[267.2,241.3],[267.8,236.3],[259.9,220.9],[253.8,183],[239.1,180.4],[237.3,142.9],[232.3,137.4],[234.3,125.2],[245.8,109.6],[260.4,92.9],[274.5,86.2],[272.7,74.3],[280.5,66.8],[311.7,63.6],[313.2,60.8],[310.7,52.8],[310.1,22.8],[313.1,7.8],[309.3,4.3],[294.9,3.9],[282.7,0],[252.3,38.9],[225.4,59.9],[221.5,82.4],[167,106.2],[154.4,115.6],[146.6,117.7],[138.6,55.9],[121.6,64.9],[128.9,94.6],[129.8,125.4],[129.3,139.9],[123.8,147],[133.9,163.8],[137,184.9],[130.5,232.9],[137.2,241.9],[160.7,257.6],[162.4,264.2],[156.9,273.7],[138.4,287.8],[135.3,293.3],[141.4,333.7],[153.9,362.4],[153,381.1],[150,391.5],[132.8,420.5],[138.6,439.9],[125.9,455.7],[122.8,472.7],[231.2,467.3],[225.6,446],[229.2,438.9],[228.2,432.9],[234.3,422.7],[229.8,414.7],[260.7,412.5],[262.5,448.4],[286.8,464.6]]],"center":[208.2,264.6],"label":[200.1,237]},{"id":"150303","name":"海南区","rings":[[[299.6,465.5],[286.8,464.6],[262.5,448.4],[260.7,412.5],[229.8,414.7],[234.3,422.7],[228.2,432.9],[229.2,438.9],[225.6,446],[231.2,467.3],[122.8,472.7],[130,500.6],[129.6,523.9],[121,538.2],[124.1,555.4],[113.3,565.2],[112.4,573.2],[123.2,603.5],[124.5,614.5],[131.8,619],[149.5,619.7],[157.7,631.3],[164,646.7],[159.8,660.6],[172,683.9],[167.4,708.2],[171.4,727],[158.3,761.7],[157.8,767.7],[165.1,778.2],[162.1,798.5],[188.1,821.9],[195.5,855],[217.5,919.2],[237.2,939.1],[256.2,946.3],[258.7,949.7],[282.2,954.7],[289.6,960.7],[293.3,971.1],[301.4,971.9],[307.7,979.5],[315.9,980],[330.2,993.2],[332.1,999],[335.3,1000],[343.4,994.6],[351.7,996.7],[359,993.7],[365.3,999.6],[372.4,999.2],[380,986.9],[385.8,986],[379.3,979.6],[380.9,973.9],[389.7,975.4],[394.2,971.3],[396.4,972.4],[400.9,976.8],[403.1,988],[409.6,989.4],[422,947.6],[419.5,913],[432.3,886],[446.3,804.1],[463.8,783.7],[452.9,771.3],[453.7,752.3],[463.2,737.3],[461.3,724.1],[393.8,788.7],[387.3,771.2],[291.3,702.2],[291,686.3],[305.2,655.3],[310.6,530.9]]],"center":[273.4,730],"label":[231.7,705.6]},{"id":"150304","name":"乌达区","rings":[[[124.3,552],[121,538.2],[122.1,534.5],[129.6,523.9],[128.3,519.1],[130,500.6],[122.8,472.7],[123,465.7],[125.9,455.7],[138.6,439.9],[138.3,435.1],[133.9,428.6],[132.6,424.7],[132.8,420.5],[134.5,415.6],[150,391.5],[152.2,386.6],[153,381.1],[153.9,362.4],[152.4,356.5],[141.4,333.7],[139.1,321.5],[24.8,389.5],[22.8,392.5],[23.8,397.8],[13.6,406.1],[7.5,413.7],[0,427.1],[0.1,439.7],[7.7,474.4],[13.5,483],[17.4,485.1],[24.7,495.3],[29.2,499],[41.2,502.1],[47.9,512],[50.5,519.9],[58.6,522.6],[85.2,535.5],[88.1,535.9],[94.8,540.6],[101.6,541],[115.7,545.2]]],"center":[81.9,436.3],"label":[69.2,437.4]}]});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
window.PuzzleAtlas.register({"id":"152900","name":"阿拉善盟","province":"150000","width":1000,"height":727.37,"edges":[[0,1,0],[1,2,0]],"kind":"county","parent":"150000","provinceName":"内蒙古自治区","playLabel":"县区拼图","unitCaption":"市辖区 · 县 · 县级市","scopeNote":"左右调整待落块,松手落下,拼完整片阿拉善盟。","regions":[{"id":"152921","name":"阿拉善左旗","rings":[[[713.6,462.4],[709.2,478.6],[722.6,497.9],[724.5,519.7],[708.8,532.1],[689.8,565.2],[644.1,596.8],[656.3,629.4],[639.1,638.3],[642.6,668],[670.2,677.9],[723.2,727.3],[746.3,719.8],[749,713.2],[809.9,704.8],[839.9,688.9],[869.2,689.2],[871.6,679],[885.9,676.1],[890.6,667.5],[894.1,648.9],[887.4,625.5],[896.7,611],[892.2,601.7],[895.5,565.4],[900.1,546.1],[910.9,534.6],[907.8,529.9],[916.8,522.3],[925.6,497.8],[940.1,498.5],[939.9,482.2],[962.8,482.2],[972.7,468.2],[988.2,467.3],[973.6,446.1],[992.7,426.9],[985.5,379],[999.9,359.5],[986.9,358],[983.4,349.1],[994.5,334.5],[944.5,337.7],[932.2,314.7],[939.6,311.5],[962,319.2],[960.1,309.4],[939.5,303.5],[929.4,289.1],[880.1,274.7],[848.5,251],[830.1,196.5],[831.5,145.6],[808.4,168.7],[799.5,159],[758.3,157.9],[759,128.4],[689.5,138.7],[644.3,127.4],[655.5,158.9],[687.8,187.6],[729.2,271.3],[736.6,270.3],[760,307.2],[764.9,322.1],[759.2,329.9],[768.9,337.1],[763.8,348.9],[770.3,364.1],[725.8,381.5],[730.8,386.2],[725,405.7],[734.3,435.3],[725.9,456.4]]],"center":[810.6,446.4],"label":[862.1,427.5]},{"id":"152922","name":"阿拉善右旗","rings":[[[713.6,462.4],[725.9,456.4],[727.2,445.5],[734.3,435.3],[725,405.7],[730.8,386.2],[725.8,381.5],[770.3,364.1],[763.8,348.9],[768.9,337.1],[759.2,329.9],[764.9,319.7],[759.6,317.6],[761.9,311],[736.6,270.3],[729.2,271.3],[715.8,235.7],[687.8,187.6],[655.5,158.9],[644.3,127.4],[593.6,101.9],[591.4,123.7],[601.4,144.3],[607.7,191.3],[611.8,195.8],[601.4,209.3],[499.3,282.4],[475.4,312.4],[461.1,316],[450.9,327.1],[438.7,327.4],[421.7,320.1],[383.1,324.1],[361,314.1],[342.6,315.5],[325.5,329.7],[309.1,327.6],[309.1,347],[290.6,359.1],[279.4,380.3],[266,383.6],[242.6,399.5],[234,399.8],[273.3,403.3],[281.8,413.3],[317.4,426.8],[324.1,437.3],[325.3,450.3],[343.3,454.1],[343.1,464.8],[378.5,464.2],[380.9,501.6],[377.8,514.7],[390.8,517.4],[391.6,525.2],[418.4,515.4],[415.3,525.7],[419.7,536.5],[429.3,538.3],[427.5,546.8],[452.3,555.9],[456.9,563.8],[464.8,559.1],[475,563.1],[505.7,532.6],[481.1,505.5],[544.8,484.2],[560,495.3],[601.7,504.9],[637.9,473.4],[687.7,456.9]]],"center":[544.8,365.8],"label":[535.4,331.7]},{"id":"152923","name":"额济纳旗","rings":[[[309.1,327.6],[325.5,329.7],[342.6,315.5],[361,314.1],[383.1,324.1],[421.7,320.1],[438.7,327.4],[450.9,327.1],[461.1,316],[475.4,312.4],[492,288.2],[601.4,209.3],[611.8,195.8],[607.7,191.3],[601.4,144.3],[591.4,123.7],[593.6,101.9],[571.4,89.8],[553.8,88.5],[544.3,91.1],[505.8,80],[477.7,40.9],[453.6,38],[380.6,17.4],[325.2,14.8],[319.8,22.3],[288.5,20.7],[240.8,31.9],[105.5,19.9],[0,0],[69.6,158.6],[45.8,185.1],[82.5,235.4],[111.2,257.1],[119.7,259.9],[120.9,307.6],[150.1,292.7],[144.1,283.3],[154.3,279.8],[157.3,292.4],[167.9,301.9],[166.9,289],[186.9,278.5],[192.8,290.6],[199,292.9],[206.4,283.3],[206.5,268.2],[246.9,269.7],[257.9,258],[297.5,261.4],[316.1,287.4],[316.7,300.8],[309.2,311.3]]],"center":[310.9,157.3],"label":[334.4,163]}]});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
window.PuzzleAtlas.register({"id":"230900","name":"七台河市","province":"230000","width":1000,"height":518.93,"edges":[[0,1,0],[0,2,0],[1,2,0],[0,3,0],[1,3,0],[2,3,0]],"kind":"county","parent":"230000","provinceName":"黑龙江省","playLabel":"县区拼图","unitCaption":"市辖区 · 县 · 县级市","scopeNote":"左右调整待落块,松手落下,拼完整片七台河市。","regions":[{"id":"230902","name":"新兴区","rings":[[[456.4,392.5],[443.5,371.8],[453.3,354.2],[467.8,358.8],[474.6,342],[491.2,358.8],[530.8,341.7],[582.2,290.6],[592.9,313.7],[602.5,311.1],[625.3,346.5],[648.9,325.2],[646.4,337.9],[683.9,347],[686.7,365.3],[702.6,367.2],[707.4,324.9],[717,315.5],[712.1,278.4],[758.2,251.5],[783.9,191.9],[780,152.3],[789.4,139.2],[806.6,136.2],[804.7,124.4],[786,118.4],[771.9,76.2],[790.4,43.8],[773.3,20.5],[759.8,41.2],[766.1,1.2],[729.2,7.3],[713.9,1.2],[707.5,14.8],[721.8,25.3],[703.6,92.5],[694.2,102.4],[678.7,97.8],[675.7,104.2],[676.2,114.2],[689.3,108.2],[692.7,125.6],[691.5,138.9],[674.6,143.2],[683.7,182],[665.4,187.9],[644.4,222.5],[643.7,171],[629.6,152.3],[611.9,158.8],[604.2,177.7],[574.1,173.8],[568,191.7],[524.1,144.2],[507.7,180.4],[462.7,177.8],[451.9,187.8],[429.6,176.1],[401.2,177.9],[391.3,187.8],[363.5,188.5],[356.6,202.5],[340.8,194.6],[320.5,202],[329.9,251.9],[361.6,266.9],[387.7,292.9],[395.3,287.2],[400,298.2],[386.4,303.4],[390.9,311],[368.6,332.1],[399.5,377.1],[398.8,398.3]]],"center":[578.3,230.4],"label":[501.1,201]},{"id":"230903","name":"桃山区","rings":[[[503.9,353.8],[497.6,356.7],[491.2,358.8],[489.1,357.4],[486.7,357.8],[483.9,356.9],[481.6,355.5],[481.4,352.6],[480.6,350.8],[479.2,343.5],[474.6,342],[473,342],[472.8,346.5],[470.4,351.5],[471.2,352.6],[469.4,354.2],[469.6,356.9],[468.4,357.2],[467.8,358.8],[463.2,359.3],[461.8,358.8],[459.9,356.8],[458.5,356.9],[456.6,355.9],[454.9,354.3],[454.1,353.9],[453.3,354.2],[451.7,359.1],[447.6,363.4],[448.1,371.7],[443.5,371.8],[447.3,377.5],[448.7,377.1],[451.1,377.6],[456.1,382.9],[455.1,390.6],[456.4,392.5],[459.5,392.3],[460.3,396.7],[463.5,397.8],[464.2,399.2],[468.2,400.6],[468.7,402.2],[468.5,406],[470,409.9],[473,413.4],[474.3,414.3],[475.9,420.6],[479.3,422],[480.5,423.9],[485.1,424.1],[489.6,421.8],[491,424.5],[492.4,425.6],[498,427.8],[501.5,427.9],[505,426.2],[510.1,422.6],[513.3,421.8],[515.7,420.3],[516.5,419.4],[516.7,418],[515.9,416.1],[516.7,412.3],[519.3,410.7],[522.5,406.5],[522.8,405.3],[520.2,403.9],[516.4,400.7],[512.7,399.2],[512.3,398.6],[514.4,397.9],[515.2,396.9],[515.4,394.9],[514.2,391],[514.4,386.5],[511.7,380.1],[511.4,371.8],[508.7,365.8],[506.7,359.1]]],"center":[485.7,387],"label":[484.6,385]},{"id":"230904","name":"茄子河区","rings":[[[501.5,427.9],[512.8,471.9],[543.7,475.6],[546.3,484.1],[573.5,474.6],[589.1,494.6],[596.1,481.1],[611.5,496.6],[644.3,489.1],[644.4,503.9],[656.4,512.5],[693.2,455.3],[674.4,445.3],[682.3,431.3],[705.8,414.9],[751.3,413.7],[736.4,345.3],[763,322.4],[774.5,291.5],[794.5,291.7],[803.1,264.2],[821.6,255.6],[841.3,267.8],[857.4,260.6],[864.4,271.3],[882.2,263.1],[888.1,282.6],[918.9,274.6],[967.7,207.8],[999.7,191.5],[983,178.3],[996.8,134.9],[977.7,128.2],[948,147.3],[927.7,144.6],[916.3,151.9],[909.3,140],[874.6,123.9],[871.2,71.8],[887.8,57.2],[883.7,38.8],[848.7,28.9],[834.5,37],[834,25.9],[812.9,38.4],[803.1,48.2],[805.2,76.7],[779,96.6],[786,118.4],[804.7,124.4],[806.6,136.2],[789.4,139.2],[780,152.3],[783.9,191.9],[758.2,251.5],[712.1,278.4],[717,315.5],[707.4,324.9],[702.6,367.2],[686.7,365.3],[683.9,347],[646.4,337.9],[648.9,325.2],[625.3,346.5],[602.5,311.1],[592.9,313.7],[582.2,290.6],[532.9,340.5],[503.9,353.8],[512.3,398.6],[522.8,405.3]]],"center":[743.2,285.6],"label":[762.2,269.3]},{"id":"230921","name":"勃利县","rings":[[[322.4,239.5],[301.7,238.8],[298.8,226.1],[257.8,209],[260.7,203.8],[245.5,194],[244.2,181.7],[231.8,180.9],[209.6,159],[199.9,163.2],[179.6,152.1],[174.7,134.2],[166.5,138.7],[142.5,123.6],[135,127.8],[123,117.9],[102,119.5],[74.9,101.5],[59.8,109.6],[43,131.9],[21.3,142.9],[7.5,141.4],[4.3,154.1],[13.1,164.1],[0,213.2],[3.4,243],[6.8,255.4],[40.4,273.7],[50.2,297.6],[43.5,299.1],[40,313.7],[52.3,329.5],[41.8,341.5],[50.8,351.2],[46.2,372.6],[52.9,387],[32.4,435.6],[43.6,450.1],[58.9,440.1],[58.7,469.8],[79.8,469.6],[88.4,457.6],[99.2,483.1],[130,496.3],[140,472.1],[161,451.7],[198.3,488.3],[206.1,505.5],[239.2,487.1],[251,495.7],[256.3,509.9],[288.3,519.9],[300.8,518.4],[307.4,500.2],[319.1,499.5],[336.6,466.7],[370.9,498.2],[388.4,489.3],[412.5,496.8],[416.1,485.1],[432.5,503.9],[456,492.1],[459.5,459.5],[496.7,448.2],[502.2,428.1],[475.9,420.6],[459.5,392.3],[422,396],[418.7,390.9],[412.3,401.6],[398.8,398.3],[399.5,377.1],[368.6,332.1],[390.9,311],[386.4,303.4],[400,298.2],[395.3,287.2],[387.7,292.9],[361.6,266.9],[329.9,251.9]]],"center":[210.6,331.2],"label":[216.2,310.7]}]});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
window.PuzzleAtlas.register({"id":"232700","name":"大兴安岭地区","province":"230000","width":1000,"height":937.99,"edges":[[0,2,0],[0,3,0],[2,3,0],[2,1,1]],"kind":"county","parent":"230000","provinceName":"黑龙江省","playLabel":"县区拼图","unitCaption":"市辖区 · 县 · 县级市","scopeNote":"左右调整待落块,松手落下,拼完整片大兴安岭地区。","regions":[{"id":"232701","name":"漠河市","rings":[[[530.8,61.5],[522.7,52.8],[503.3,61.9],[463.7,23.7],[430.2,18.8],[417,3.9],[405.4,18.9],[406.4,6.8],[399.3,1.5],[393.7,18.5],[388.9,7.8],[358.7,0],[335.6,18.8],[319.7,16.3],[284.3,30.5],[214.2,34.2],[197.8,16.9],[181.5,28.7],[168.8,26.3],[156.4,40.4],[118.6,39.4],[88.1,49.1],[54.2,65],[84.9,92.4],[81.1,110.3],[97.6,119.3],[108.1,145.2],[73.2,191.8],[69.9,210.9],[0,275.3],[24.5,282],[57.6,314.6],[79.6,318.6],[90.9,353.4],[112.7,363.3],[130.9,358.9],[167,298.2],[175.4,311.1],[192.9,309.4],[198.3,326.5],[211,337.7],[216.3,332.2],[221.6,359.5],[240,367.9],[255.9,365],[274.4,371.7],[295.1,358.7],[310.1,359.7],[338,346.6],[348.4,350.4],[378.2,337.9],[386,318.4],[396.7,316.4],[403.4,305.8],[389,252.5],[390,229.2],[400,226.5],[418.2,195],[437.4,188.6],[456.7,167.1],[452.5,153.9],[478,114.2],[494.6,99.3],[511.7,97]]],"center":[260.9,178.2],"label":[262.6,183.2]},{"id":"232718","name":"加格达奇区","rings":[[[506.2,834.1],[494.9,834],[496.6,841.1],[486.3,841.5],[485.8,846.9],[479,849.1],[479.8,852.5],[482,853.8],[480.7,867.6],[482.7,869.4],[477.3,878.6],[471.5,879.4],[468.2,885.9],[462.8,883],[461.2,878],[454.5,875.6],[452,865.4],[447.7,863.7],[444.3,868.7],[445.7,873.3],[441.7,884.6],[442.8,886.8],[445.4,885.7],[443.8,893.6],[446.3,894.5],[447.2,899.1],[449.2,899.9],[450.6,903.7],[453.3,903.6],[454.1,906.9],[458.6,909.4],[459.7,913.4],[458,923],[461.1,929.9],[466.3,929.5],[469.9,935.7],[473.9,935.7],[477.8,929.9],[481.8,929.2],[483.1,927.1],[492.4,932.5],[498.6,928.2],[499.3,921.9],[503,921.7],[509.1,925.2],[511.6,922.3],[513.6,923.9],[514.1,927.7],[521.7,924.4],[530.1,924],[529,926.4],[530.8,935],[537.7,938],[543.3,931.7],[540.6,927.1],[542,918.8],[544.8,916.7],[541.1,908.4],[545.7,902.6],[541.2,901],[540.8,896.9],[544,889.2],[550.8,888.7],[551.7,886.3],[557,882.6],[553,865.4],[554.1,863.6],[557,863],[557.7,859],[555.9,848.8],[557,841.1],[554.7,838.9],[549,839.1],[543.2,842.4],[540.3,841.7],[537,843],[531.2,837.5],[516.4,837]]],"center":[505.4,885.2],"label":[511.3,885.5]},{"id":"232721","name":"呼玛县","rings":[[[790.1,191],[750.6,267.3],[761.7,305.6],[794.9,334.2],[721.8,348.2],[708.3,389.9],[649.4,395.8],[645.3,372.7],[609.1,351.6],[591.2,374.6],[565.9,374.1],[527.6,399.3],[522.8,379.9],[492,372.6],[507.4,338.9],[484.1,339.9],[463.1,323.7],[381.3,362.3],[362.7,341.2],[274.4,371.7],[266,399.4],[247.4,404.1],[246.7,423.3],[264,447.9],[285.9,586.3],[311.2,631.6],[357.1,647.1],[386.1,642.9],[424,629.7],[432.4,608.1],[469.1,634.8],[520.2,621.3],[551.3,642.6],[565.6,613],[590.4,627.7],[633.1,610.9],[671.4,535.7],[716,548.8],[782.3,654.8],[822.6,684.4],[836.6,726.3],[827.1,738.2],[865.4,715.9],[905.2,743.3],[995.7,735.5],[977.6,662.1],[991.5,627.6],[973.6,630.5],[979.1,649.5],[964.9,642.3],[981.7,611.1],[959,598.7],[968.1,572],[940,554.5],[949.3,521.9],[902.8,456.4],[900.8,434.4],[919.5,404.8],[876.2,384.8],[898.1,359.1],[879.8,355.5],[884.2,333.5],[870,309.6],[856.3,309.7],[859,292.5],[820.2,270.8],[843.6,226.9],[829.8,218.1],[818,229.2]]],"center":[636.6,495.7],"label":[587.5,466.1]},{"id":"232722","name":"塔河县","rings":[[[530.8,61.5],[526.1,78.2],[515.5,83.5],[511.7,97],[494.6,99.3],[478,114.2],[475.1,126.1],[452.5,153.9],[456.7,167.1],[437.4,188.6],[418.2,195],[400,226.5],[390,229.2],[389,252.5],[403.4,305.8],[396.7,316.4],[386,318.4],[379.5,336.7],[366.4,342.2],[366.9,349.8],[385.3,361.3],[417.9,345.4],[421.9,337.9],[438.6,335.9],[445.7,326.4],[463.1,323.7],[484.1,339.9],[491.5,332.8],[503,335.1],[507.8,343.3],[493.3,359.1],[492,372.6],[505.2,380.2],[522.8,379.9],[527.6,399.3],[546.4,392.2],[565.9,374.1],[591.2,374.6],[609.1,351.6],[632.1,363.3],[631.9,370.1],[645.3,372.7],[641.1,383.6],[650.2,388.9],[649.4,395.8],[704.7,391],[712.7,379.5],[710.5,362.4],[721.8,348.2],[789.2,341.1],[794.3,331.1],[781.6,324.2],[777.9,311],[763.4,307.3],[759.6,293.5],[752.4,290.4],[750.6,267.3],[761.3,262.9],[752.5,245.5],[790.1,191],[766.7,198.1],[766.5,185.8],[778.7,177.2],[781.3,165.3],[769.8,158.9],[759.5,138.8],[741.9,147],[736.9,132.4],[677,103],[650.4,105.2],[633.5,114.4],[637.3,127.6],[631,133],[624.2,119.9],[604.7,117.3],[606.1,108],[599.5,102.7],[570.6,103.2],[556.3,97.4]]],"center":[590.2,242.9],"label":[575.8,230]}]});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user