guoziyun преди 3 седмици
ревизия
c886c8ee89
променени са 74 файла, в които са добавени 12546 реда и са изтрити 0 реда
  1. 20 0
      .gitignore
  2. 247 0
      README.md
  3. 331 0
      agent.md
  4. 35 0
      assets/css/loading.css
  5. 472 0
      assets/css/setting.css
  6. 597 0
      assets/css/tools.css
  7. BIN
      assets/fonts/numbers_roboto_500.png
  8. BIN
      assets/img/coloring-pages.png
  9. BIN
      assets/img/finger.png
  10. BIN
      assets/img/finger2.png
  11. BIN
      assets/img/logo-txt.png
  12. BIN
      assets/img/logo.png
  13. BIN
      assets/img/slogon.png
  14. 1 0
      assets/res/6a18f7d9957ac783bad75479/config.json
  15. BIN
      assets/res/6a18f7d9957ac783bad75479/map.png
  16. BIN
      assets/res/6a18f7d9957ac783bad75479/page.png
  17. BIN
      assets/res/6a18f7d9957ac783bad75479/special.jpeg
  18. BIN
      assets/sound/color_done_02.mp3
  19. BIN
      assets/sound/section_done.mp3
  20. BIN
      assets/sound/sound_hint.mp3
  21. 11 0
      dist/index.html
  22. 68 0
      index.html
  23. 1080 0
      package-lock.json
  24. 20 0
      package.json
  25. 183 0
      share.html
  26. 164 0
      src/base/2d.ts
  27. 88 0
      src/base/Animator.ts
  28. 144 0
      src/base/BgLayer.ts
  29. 106 0
      src/base/BorderLayer.ts
  30. 106 0
      src/base/BoxLayer.ts
  31. 114 0
      src/base/DebugLayer.ts
  32. 112 0
      src/base/FrameLayer.ts
  33. 247 0
      src/base/Gesture.ts
  34. 40 0
      src/base/ImageLayer.ts
  35. 487 0
      src/base/ImageShaders.ts
  36. 324 0
      src/base/Scene.ts
  37. 145 0
      src/base/TextureLayer.ts
  38. 95 0
      src/base/Triangle.ts
  39. 56 0
      src/base/glsl/BicubicHermite.glsl
  40. 73 0
      src/base/glsl/BicubicLagrange.glsl
  41. 20 0
      src/base/glsl/Bilinear.glsl
  42. 34 0
      src/base/glsl/GaussianBlur.glsl
  43. 18 0
      src/base/glsl/avg5.glsl
  44. 29 0
      src/base/glsl/avg9.glsl
  45. 55 0
      src/base/index.html
  46. 51 0
      src/base/index.ts
  47. 1468 0
      src/base/m4.ts
  48. 181 0
      src/base/utils.ts
  49. 1195 0
      src/base/webgl-debug.kk
  50. 475 0
      src/filler/AnimatableMask.ts
  51. 41 0
      src/filler/Audio.ts
  52. 259 0
      src/filler/FillerData.ts
  53. 58 0
      src/filler/FillerScene.ts
  54. 147 0
      src/filler/FingerHint.ts
  55. 282 0
      src/filler/HintLayer.ts
  56. 86 0
      src/filler/LineArtLayer.ts
  57. 33 0
      src/filler/Loader.ts
  58. 53 0
      src/filler/LoadingController.ts
  59. 221 0
      src/filler/Mask.ts
  60. 255 0
      src/filler/NumberLayer.ts
  61. 242 0
      src/filler/WorkLayer.ts
  62. 255 0
      src/filler/common.ts
  63. 170 0
      src/filler/createColored.ts
  64. 47 0
      src/filler/cta.ts
  65. 114 0
      src/filler/explosion.ts
  66. 590 0
      src/filler/index.ts
  67. 71 0
      src/filler/mraid.ts
  68. 200 0
      src/filler/play.ts
  69. 29 0
      src/utils/random.ts
  70. 104 0
      tsconfig.json
  71. 3 0
      typings/svg-mesh-3d.d.ts
  72. 1 0
      typings/vite-env.d.ts
  73. 377 0
      typings/zingtouch.d.ts
  74. 16 0
      vite.config.js

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+# Add any directories, files, or patterns you don't want to be tracked by version control
+
+node_modules/
+public/bower/
+public/data
+public/app
+build
+data/
+.DS_Store
+*.log
+*.gzip
+*.zip
+._*
+test/*.png
+test/*.svg
+core.*
+report.*.json
+.vscode/
+zorro/.vscode/
+.eslintrc.js

+ 247 - 0
README.md

@@ -0,0 +1,247 @@
+# Playable Ads
+
+本项目是一个基于 Vue3 + TypeScript + Vite 的项目,核心是基于webgl的web端填色,用于创建可播放的广告。
+
+## 背景
+
+我们有一个填色应用app,已经上架到google play 和 app store。 但是投放的广告基本上是静态图片或视频, 没有playable的广告, 本项目立足制作playable的广告, 用于提升广告的互动性和效果。
+
+playable广告的核心是H5页面, 本工程已经实现了基于webgl的web端填色核心玩法,现在需要进一步完善制作成可播放的广告。
+
+## 竞品分析
+
+以下是几家竞品的playbale 广告, 可以作为分析参考, 尤其是页面的规范方面。
+
+### Paint by Number:
+
+1.
+
+```
+https://creative-ag-global.umcdn.cn/html/14/df/cf/14dfcf467412065a43ec4d2f1ad63971.html
+```
+
+主要展示了一屏十几个填色作品的填列表页,所谓互动的部分其实就是可以滑动该列表页,点击某个列表页进入一个详情页,这属于简单的内容互动展示,没有涉及游戏核心玩法
+
+2.
+
+```
+https://creative-ag-global.umcdn.cn/html/df/3a/14/df3a14870fcb67b02292a0eac34c5ab1.html
+```
+
+可以滑动选择几个有吸引力的上色作品,点击进入填色详情页, 但同样不能进行填色操作,没有涉及游戏核心玩法,本质还是简单的内容互动展示
+
+### Happy Color
+
+1.
+
+```
+https://creative-ag-global.umcdn.cn/html/83/d5/81/83d5810d37e36b92ee5d90517e31b0f9.html
+```
+
+页面动效较好, 但也属于内容展示,没有体现核心玩法, 能够互动的部分就是一个手指指向的 PLAY NOW 按钮,点击即进入安装弹框
+
+2.
+
+```
+https://creative-ag-global.umcdn.cn/html/85/8a/03/858a031936edfda1009dd6acb5c3ec2f.html
+```
+
+这个有涉及一点核心玩法,有提示手型,可点击上色。
+
+### Color asis
+
+```
+https://creative-ag-global.umcdn.cn/html/09/2d/44/092d441b80639f462fa667bb3f8964bf.html
+```
+
+这个有涉及核心玩法, 可点击上色,结束后撒花动画,展示典型内容,显示“Over 10,000+ pictures”, 可以作为重点对标研究对象
+
+## playable广告规范
+
+各家广告平台对于playable的广告规范如下, 需要仔细研读理解,我们最终发布的内容要符合所有平台的规范:
+
+1. mintergal
+
+```
+https://www.playturbo.com/review/doc
+```
+
+2. applovin
+
+```
+https://p.applov.in/playablePreview?create=1
+```
+
+3. unity
+
+```
+https://docs.unity.com/zh-cn/grow/acquire/creatives/playable/specifications
+```
+
+4. google admod
+
+```
+https://support.google.com/google-ads/answer/9981650?hl=en#_HTML
+```
+
+gemini关于playable广告的讲解:
+
+```
+https://gemini.google.com/share/8340c20dd2d1
+```
+
+## 项目目标
+
+制作符合各广告平台的playable ads, 当前可聚焦核心填色玩法的模版, 未来可以可能开发更多模版。
+
+## 技术栈
+
+| 层级         | 技术                                                |
+| ------------ | --------------------------------------------------- |
+| 渲染引擎     | WebGL 2(自研,无外部 GL 框架)                     |
+| 构建工具     | Vite 5 + TypeScript 5                               |
+| 打包插件     | vite-plugin-singlefile(输出单文件 HTML)           |
+| 广告生命周期 | MRAID 2.0 + Unity DAPI                              |
+| 跳转优先级   | `mraid.open()` → `ExitApi.exit()` → `window.open()` |
+
+## 项目结构
+
+```
+src/
+  filler/         # 广告主逻辑
+    index.ts      # 入口,整合加载/CTA/平台初始化
+    cta.ts        # CTA 按钮点击 + 高亮动画
+    mraid.ts      # 平台生命周期适配(MRAID / Unity DAPI)
+    FillerScene.ts# WebGL 填色场景
+    FingerHint.ts # 指引手指 DOM overlay
+    Loader.ts     # 资源加载
+  base/           # 自研 WebGL 2 渲染层
+assets/
+  res/            # 填色资源包(config.json + 图片)
+  css/            # 样式
+dist/
+  index.html      # 最终产物,单文件自包含 HTML(~985 KB)
+```
+
+## 开发调试
+
+### 安装依赖
+
+```bash
+npm install
+```
+
+### 启动本地开发服务器
+
+```bash
+npm run dev
+```
+
+### 映射端口到云服务器
+
+```bash
+ssh -R 0.0.0.0:5173:localhost:5173 ecs
+```
+
+然后可以在浏览器打开:
+
+```
+http://42.193.231.145:5173
+```
+
+## 构建
+
+```bash
+npm run build
+```
+
+输出文件:`dist/index.html`(单文件,所有 JS/CSS/图片/字体均内联)
+
+| 指标         | 数值                 |
+| ------------ | -------------------- |
+| 文件大小     | ~985 KB              |
+| Gzip 大小    | ~641 KB              |
+| 平台大小限制 | 5 MB(各平台均满足) |
+
+> **注意**:构建前请确认 `src/filler/cta.ts` 中的 `STORE_URL_IOS` / `STORE_URL_ANDROID`:
+>
+> - **测试阶段**:使用 PBN 落地页(当前默认),避免产生无效转化
+> - **正式上线**:换回自己 app 的 Store 链接(文件内有注释说明)
+
+## 多平台测试
+
+### 测试前准备
+
+1. 执行 `npm run build`,确认 `dist/index.html` 为最新产物
+2. 检查 `cta.ts` 中的落地页 URL 符合当前测试目的
+
+### Applovin
+
+**预览工具**:https://p.applov.in/playablePreview?create=1
+
+1. 打开预览工具,上传 `dist/index.html`
+2. 检查项:
+   - [ ] 广告正常加载,WebGL 填色可交互
+   - [ ] CTA 按钮可点击(Applovin 通过 `ExitApi.exit()` 关闭广告,落地页由平台后台配置)
+   - [ ] 手指引导动画正常显示
+   - [ ] 完成填色后撒花 + 结算屏正常展示
+
+### Unity Ads
+
+**规范文档**:https://docs.unity.com/zh-cn/grow/acquire/creatives/playable/specifications
+
+1. 在 Unity Creative 后台上传 `dist/index.html`
+2. 检查项:
+   - [ ] `dapi.gameReady()` 被正确调用(可在控制台确认)
+   - [ ] CTA 跳转正常(Unity 使用 MRAID `mraid.open(url)` 跳转)
+   - [ ] 广告尺寸符合规范(单文件 HTML,无外部请求)
+
+### Google AdMob / Google Ads
+
+**规范文档**:https://support.google.com/google-ads/answer/9981650?hl=en#_HTML
+
+1. 使用 Google Web Designer 或 HTML5 验证工具检验
+2. 检查项:
+   - [ ] 无外部网络请求(所有资源均已内联)
+   - [ ] 文件大小满足限制(≤5 MB)
+   - [ ] CTA 可正常触发 `window.open(url)`
+   - [ ] 不依赖 `document.write` 或被禁用 API
+
+### Mintergal / Playturbo
+
+**规范文档**:https://www.playturbo.com/review/doc
+
+1. 按平台文档要求上传或提交审核
+2. 检查项:
+   - [ ] MRAID 初始化流程正常
+   - [ ] 音频在用户首次交互后解锁(iOS 限制)
+   - [ ] CTA 跳转正常
+
+### 真机测试
+
+在真实设备上直接用浏览器打开 `dist/index.html`(可通过云服务器地址访问):
+
+```
+http://42.193.231.145:5173   # dev 模式
+# 或将 dist/index.html 放到静态服务器后访问
+```
+
+| 设备    | 浏览器  | 检查项                                         |
+| ------- | ------- | ---------------------------------------------- |
+| iOS     | Safari  | 音频首次交互解锁、手势填色、CTA 跳转 App Store |
+| Android | Chrome  | 音频、手势、CTA 跳转 Google Play               |
+| Android | WebView | 广告平台内嵌 WebView 行为是否一致              |
+
+## 常见问题
+
+**Q: 填色区域点击没反应?**
+A: 检查 WebGL context 是否初始化成功,Console 有无报错。
+
+**Q: 音效不播放(iOS)?**
+A: iOS 要求用户交互后才能播放音频,代码已有 unlock 逻辑,确认首次点击后音效正常即可。
+
+**Q: CTA 点击没有跳转?**
+A: 各平台跳转机制不同——Applovin 由平台后台控制落地页;MRAID 环境下用 `mraid.open(url)`;其余环境用 `window.open(url)`。
+
+**Q: 构建产物超过平台大小限制?**
+A: 检查 `assets/res/` 下的图片资源,压缩大尺寸图片后重新构建。

+ 331 - 0
agent.md

@@ -0,0 +1,331 @@
+# Playable Ads — Agent 工作文档
+
+> 面向 AI Agent 辅助开发的上下文与行动计划。人工协作时同样可用于 Sprint 规划。
+
+---
+
+## 1. 项目现状快照(2026-05)
+
+### 1.1 技术栈
+
+| 层面        | 技术                                             |
+| ----------- | ------------------------------------------------ |
+| 构建        | Vite 5 + TypeScript 5                            |
+| 渲染        | WebGL 2(无外部 GL 框架)                        |
+| 资源加载    | 本地 `assets/res/` 静态文件(fetch JSON/PNG)    |
+| 动效/完成态 | js-confetti、Canvas 粒子爆破(自研 `Explosion`) |
+| 音效        | Web Audio API via HTMLAudioElement               |
+| 外部依赖    | js-confetti(jszip 已移除,playable 不需要)     |
+
+### 1.2 文件树职责
+
+```
+index.html          # playable 广告主入口(填色玩法页)
+share.html          # 分享落地页(回放 + Download CTA)
+src/
+  base/             # 渲染引擎层
+    Scene.ts        # 场景根节点:图层管理、变换矩阵、手势、动画帧
+    Gesture.ts      # 捏合/拖拽/点击手势抽象
+    Animator.ts     # 值驱动动画(Interpolator 可替换)
+    m4.ts           # 4×4 矩阵运算(纯 TS 实现)
+    BgLayer / BoxLayer / BorderLayer / FrameLayer / ImageLayer / TextureLayer
+    ImageShaders.ts # GLSL shader 常量池
+    glsl/           # 独立 GLSL 文件(Gaussian / Bicubic 等)
+  filler/           # 填色玩法层
+    index.ts        # 广告主入口脚本(init、UI 事件、CSS 回调)
+    play.ts         # 分享页脚本(回放逻辑)
+    FillerScene.ts  # 继承 Scene,增加 focusToArea / hint
+    FillerData.ts   # 状态核心(coloredPercent、当前 group、getArea 命中)
+    WorkLayer.ts    # 互动层:tap 填色、progress=1 动画完成后切换 group
+    AnimatableMask.ts# 掩码 FBO:每个 area 以扩散圆动画绘制到纹理
+    LineArtLayer.ts # 线稿渲染
+    NumberLayer.ts  # 数字标注(atlas sprite)
+    HintLayer.ts    # 提示高亮层
+    Loader.ts       # 原 zip 解压模块(playable 中不使用,线上网站保留)
+    FillerData.ts   # 数据模型:Area、AreaGroup、Data、FillerResource
+    LoadingController.ts # Loading overlay 显示/隐藏
+    Audio.ts        # 音效播放器
+    explosion.ts    # 粒子爆破动画
+  utils/
+    random.ts
+```
+
+### 1.3 运行时数据流
+
+```
+assets/res/<id>/ → fetch config.json + loadImage(page.png / map.png)
+  ↓  loadResource()
+  FillerResource { config(JSON), page(png), map(png), numberImage, bg, special }
+  ↓
+FillerData (状态机,管理 area.colored / currentGroup / taskList)
+  ↓
+FillerScene.addLayer(...)   // BgLayer → BoxLayer → HintLayer → NumberLayer
+                            // → WorkLayer → LineArtLayer → BorderLayer
+  ↓  requestAnimationFrame
+AnimatableMask.flush()      // 将动画进度写入 FBO
+  ↓
+WorkLayer.draw()            // 读取 FBO texture + colored texture → 渲染
+  ↓
+CSS 回调 (cssAdjustProgress / cssColorDone / cssOnFinish)
+  ↓
+完成:confetti + resetToResult + replay()
+```
+
+### 1.4 已完成能力
+
+- [x] 核心填色玩法:点击区域 → 识别 area id → 扩散动画上色 → group 完成爆破
+- [x] 进度条与颜色按钮 UI(含环形进度)
+- [x] 填色完成后撒花 + 回放动画
+- [x] 音效(colorDone / allDone / hint)
+- [x] 震动反馈
+- [x] 加载动画(spinner + 最小显示时长)
+- [x] 手势:双指缩放、拖拽、点击
+- [x] 缩放到最适合 / focus 到下一个 area
+- [x] 分享落地页(图片预览 + 播放填色回放)
+- [x] 本地资源加载(assets/res/ 目录,fetch JSON + loadImage PNG)
+- [x] 资源内嵌为 Base64(Vite `?url`/`?raw` + vite-plugin-singlefile,构建输出单文件 HTML)
+- [x] 宣传界面(promo-screen:coloring-page + slogan 入场动画)
+- [x] 手指引导提示(FingerHint DOM overlay,闲置 2s 后自动指向待填区域)
+
+### 1.5 当前主要缺口(需要本项目完成的工作)
+
+| 缺口                     | 优先级 | 说明                                                                        |
+| ------------------------ | ------ | --------------------------------------------------------------------------- |
+| **广告平台规范适配**     | P0     | 单文件内联 HTML、资源 < 5 MB、无外部依赖(applovin/google/unity/mintergal) |
+| **CTA 按钮与跳转**       | P0     | 所有平台都要求显眼 CTA,含平台 API 跳转 store                               |
+| **自动演示(引导手势)** | P1     | 游戏前 3-5 秒内有动效/提示引导用户点击上色                                  |
+| **体积优化**             | P1     | 当前依赖 js-confetti(jszip 已移除),需图片压缩 + 音效压缩                 |
+| ~~**多图片数据内嵌**~~   | ~~P1~~ | ✅ 已完成:Vite `?url` 静态导入 + vite-plugin-singlefile 全量内联           |
+| **广告生命周期 API**     | P1     | 各平台 SDK 回调(mraid / gameReady 等)                                     |
+| **横竖屏适配**           | P2     | 目前 padding 硬编码,需响应式布局                                           |
+| **埋点/事件上报**        | P2     | click / cta_click / complete 事件                                           |
+| **多题材模板**           | P3     | 当前单张图,需参数化支持不同图包                                            |
+
+---
+
+## 2. 广告平台规范摘要(重点约束)
+
+参考 README 中列出的竞品与规范链接,综合核心约束:
+
+| 规范         | 要求                                                       |
+| ------------ | ---------------------------------------------------------- |
+| 文件格式     | 单个自包含 HTML(所有 JS/CSS/图片 Base64 内联)            |
+| 文件大小     | ≤ 5 MB(Google)/ ≤ 5 MB(Unity)/ ≤ 3 MB(Applovin 建议) |
+| 外部请求     | **禁止**(Google/Applovin:所有资源必须内联)              |
+| HTTPS 重定向 | CTA 点击必须跳转到 store 链接(mraid.open / window.open)  |
+| MRAID        | Applovin/Unity 需支持 MRAID 2.0(mraid.js 注入)           |
+| 帧率         | 建议 60fps,不得阻塞主线程                                 |
+| 可玩时长     | 通常 15-60 秒内有 CTA                                      |
+| 广告标识     | 需显示"Ad"或"Sponsored"字样(部分平台)                    |
+
+---
+
+## 3. 实现路径与里程碑
+
+### 阶段 0:分析与准备 ✅ 已完成
+
+- [x] 通读代码,建立项目认知
+- [x] 研读各平台规范原文(参见 README 链接)
+- [x] 对标竞品 Color asis 广告(优先级最高的对标对象)
+
+---
+
+### 阶段 1:打包为单文件 HTML ✅ 已完成(2026-05)
+
+**目标**:`vite build` 输出一个完全自包含的单文件 `dist/ad.html`,无外部依赖。
+
+**任务拆解**:
+
+| #   | 任务                                                               | 文件                  | 状态   |
+| --- | ------------------------------------------------------------------ | --------------------- | ------ |
+| 1.1 | 简化入口:移除 URL id 解析和 zipUrl 逻辑,始终调 `loadResource()`  | `src/filler/index.ts` | ✅     |
+| 1.2 | 图片资源以 Vite `?url` 静态导入(构建时自动内联)                  | `src/filler/index.ts` | ✅     |
+| 1.3 | 音效 mp3 以 Vite `?url` 静态导入                                   | `src/filler/Audio.ts` | ✅     |
+| 1.4 | 配置 Vite 输出单文件(vite-plugin-singlefile + assetsInlineLimit) | `vite.config.js`      | ✅     |
+| 1.5 | 清理 `@types/jszip` devDependency                                  | `package.json`        | ✅     |
+| 1.6 | 验证输出 HTML 可独立在浏览器打开无网络错误                         | —                     | 待验收 |
+
+**验收标准**:
+
+- 单文件体积 ≤ 5 MB(超出需压缩图片)
+- `file://` 打开可完整运行,无 CORS 错误,无外部请求
+
+---
+
+### 阶段 2:CTA 与广告生命周期 ✅ 已完成(2026-05)
+
+**目标**:符合各平台规范的 CTA 流程,点击跳转 App Store / Google Play。
+
+**任务拆解**:
+
+| #   | 任务                                      | 文件                                  | 状态 |
+| --- | ----------------------------------------- | ------------------------------------- | ---- |
+| 2.1 | 新增 CTA 按钮 UI(常驻 + 完成后放大高亮) | `index.html` / `assets/css/tools.css` | ✅   |
+| 2.2 | CTA 点击跳转逻辑                          | `src/filler/cta.ts`                   | ✅   |
+| 2.3 | MRAID 2.0 适配层                          | `src/filler/mraid.ts`                 | ✅   |
+| 2.4 | gameReady / 平台初始化事件                | `src/filler/index.ts`                 | ✅   |
+| 2.5 | 完成流程强化 CTA(宣传界面出现后高亮)    | `src/filler/index.ts`                 | ✅   |
+| 2.6 | "Ad"标签(右上角半透明徽章)              | `index.html` / `assets/css/tools.css` | ✅   |
+
+**验收标准**:
+
+- CTA 按钮在 5 秒内对用户可见
+- 点击 CTA 可正确跳转(本地可通过 console.log 验证跳转 URL)
+- 完成填色后必定触发 CTA 强化展示
+
+---
+
+### 阶段 3:引导手势动画 ✅ 已完成(简化方案)
+
+**实际方案**:以 DOM overlay 实现 `FingerHint` 类代替原计划的 WebGL GuidanceLayer,效果等同且更轻量。
+
+| #   | 任务                     | 文件                       | 状态 |
+| --- | ------------------------ | -------------------------- | ---- |
+| 3.1 | 引导手型图片             | `assets/img/finger.png`    | ✅   |
+| 3.2 | DOM 悬浮手指层           | `src/filler/FingerHint.ts` | ✅   |
+| 3.3 | 800ms 后自动指向待填区域 | `src/filler/index.ts`      | ✅   |
+| 3.4 | 用户交互后重置/停止      | `src/filler/FingerHint.ts` | ✅   |
+
+---
+
+### 阶段 4:横竖屏与响应式布局(P2)
+
+**目标**:广告在主流设备(iPhone/Android 各尺寸)横竖屏均有良好视觉。
+
+**任务拆解**:
+
+| #   | 任务                           | 文件                   | 要点                                                         |
+| --- | ------------------------------ | ---------------------- | ------------------------------------------------------------ |
+| 4.1 | 底部 toolbar padding 自适应    | `src/filler/index.ts`  | `Padding` 参数根据 `window.innerWidth/Height` 动态计算       |
+| 4.2 | 颜色按钮横屏时改为侧边栏       | `assets/css/tools.css` | `@media (orientation: landscape)` 布局调整                   |
+| 4.3 | Canvas 尺寸 resize 处理        | `src/filler/index.ts`  | `window.addEventListener('resize')` 已有,需同步 canvas size |
+| 4.4 | 安全区(iPhone 刘海/Home Bar) | `index.html`           | `env(safe-area-inset-*)` CSS 变量                            |
+
+**验收标准**:
+
+- Chrome DevTools 主流设备模拟器下无元素溢出或被遮挡
+- 旋转屏幕后 canvas 正常重绘
+
+---
+
+### 阶段 5:体积与性能优化 ✅ 已完成(2026-05)
+
+**目标**:最终产出 HTML 体积 ≤ 5 MB,60fps 无卡顿。
+
+**任务拆解**:
+
+| #   | 任务                  | 状态 | 结果                                                 |
+| --- | --------------------- | ---- | ---------------------------------------------------- |
+| 5.1 | 图片压缩              | ✅   | 当前最大图 302 KB,总资源 660 KB,无需额外压缩       |
+| 5.2 | 音效压缩              | ✅   | 3 个音效合计 51 KB,已达标                           |
+| 5.3 | 评估/替换 js-confetti | ✅   | 保留,js-confetti 正常 tree-shaking 引入,体积可接受 |
+| 5.4 | Tree-shaking 验证     | ✅   | JS 81.7 KB,jszip 已排除,test() 已 tree-shaken      |
+| 5.5 | GPU 内存估算          | ✅   | page + map 两张纹理约 4 MB GPU 显存,可接受          |
+
+**验收结果**:
+
+- 输出 HTML **985 KB**(gzip **641 KB**),远低于 5 MB 限制 ✅
+- 无多余依赖,bundle 构成干净 ✅
+
+---
+
+### 阶段 6:多平台测试与提交准备(P1)← **当前阶段**
+
+**目标**:在各平台预览工具中验证通过。
+
+**任务拆解**:
+
+| #   | 任务                     | 平台                        | 工具/入口                                                                   |
+| --- | ------------------------ | --------------------------- | --------------------------------------------------------------------------- |
+| 6.1 | Applovin 预览            | Applovin MAX                | https://p.applov.in/playablePreview?create=1                                |
+| 6.2 | Unity Ads 规范自检       | Unity                       | https://docs.unity.com/zh-cn/grow/acquire/creatives/playable/specifications |
+| 6.3 | Google AdMob HTML5 校验  | Google                      | https://support.google.com/google-ads/answer/9981650                        |
+| 6.4 | Mintergal/Playturbo 预览 | Mintergal                   | https://www.playturbo.com/review/doc                                        |
+| 6.5 | 真机测试                 | iOS Safari / Android Chrome | 重点验证音效、手势、CTA 跳转                                                |
+
+**验收标准**:
+
+- 各平台预览工具无报错
+- CTA 点击在真机上可正常跳转 App Store
+
+---
+
+## 4. 关键设计决策记录
+
+### D1:内嵌资源方案
+
+**选择**:Vite build 时通过 `vite-plugin-singlefile` 将所有 JS/CSS inline,图片/音效在 TS 模块中以 Base64 字符串形式维护(构建前由脚本生成 `src/filler/assetData.ts`)。
+
+**理由**:各平台明确要求单文件自包含,Vite 原生 asset inline 在 HTML 中仍产生多个文件,plugin-singlefile 是最少侵入的方案。
+
+### D2:不引入 Vue/React
+
+**选择**:UI 控制继续沿用直接 DOM 操作。
+
+**理由**:playable 广告极度追求体积最小化,框架运行时会带来 ≥ 30 KB overhead,且当前 DOM 量极小无必要。
+
+### D3:MRAID 适配策略
+
+**选择**:在 `src/filler/mraid.ts` 实现轻量适配层,detect window.mraid 存在再调用,否则 fallback `window.open`。
+
+**理由**:广告可能运行在不同平台 WebView,统一一套代码,运行时 feature detect。
+
+---
+
+## 5. 文件变更地图(按里程碑)
+
+```
+阶段 1(单文件打包)
+  新增: src/filler/assetData.ts      ← Base64 内嵌图片/音效常量(由构建脚本生成)
+  新增: scripts/embed-assets.mjs    ← 构建前预处理脚本(图片/音效→base64 TS常量)
+  修改: src/filler/index.ts          ← 移除 URL 解析/zipUrl 逻辑,始终调用 loadResource()
+  修改: src/filler/Audio.ts          ← 音效改用 base64 data URL
+  修改: vite.config.js               ← 引入 vite-plugin-singlefile
+  修改: package.json                 ← 移除 @types/jszip devDependency
+
+阶段 2(CTA)
+  新增: src/filler/cta.ts            ← CTA 跳转逻辑
+  新增: src/filler/mraid.ts          ← MRAID 2.0 适配层
+  修改: index.html                   ← 新增 CTA 按钮 DOM
+  修改: assets/css/tools.css         ← CTA 按钮样式
+  修改: src/filler/index.ts          ← cssOnFinish 后接入 CTA 展示
+
+阶段 3(引导动画)
+  新增: src/filler/GuidanceLayer.ts  ← 手势引导 Layer
+  新增: assets/img/hand_hint.svg     ← 手型图标
+  修改: src/filler/WorkLayer.ts      ← 首次 tap 事件通知
+  修改: src/filler/index.ts          ← 启动引导动画时序
+
+阶段 4(响应式)
+  修改: assets/css/tools.css         ← 横屏 media query
+  修改: src/filler/index.ts          ← 动态 Padding 计算
+
+阶段 5(体积优化)
+  修改: src/filler/explosion.ts      ← 移除 js-confetti 依赖,自研或保留
+  修改: vite.config.js               ← 确认 tree-shaking
+  资源: assets/res/*/page.png 压缩
+  资源: assets/sound/*.mp3 压缩
+```
+
+---
+
+## 6. 风险清单
+
+| 风险                                            | 可能性         | 影响 | 缓解措施                                           |
+| ----------------------------------------------- | -------------- | ---- | -------------------------------------------------- |
+| 图片+音效内嵌后体积超 5 MB                      | 高             | P0   | 提前压缩图片至 WebP,评估 map 缩小至 512px         |
+| WebGL 2 在低端 Android WebView 不支持           | 中             | P1   | 检测 `getContext('webgl2')` 失败时展示静态降级画面 |
+| MRAID 版本差异导致 CTA 无效                     | 中             | P1   | 实现多版本 detect + fallback                       |
+| 音效在 iOS Safari 需用户手势触发                | 高(已知问题) | P2   | 首次点击上色时解锁 AudioContext                    |
+| 各平台预览工具对 inline base64 大文件的处理差异 | 低             | P1   | 提交前各平台逐一测试                               |
+
+---
+
+## 7. 参考资源
+
+- 对标竞品(最高优先): `https://creative-ag-global.umcdn.cn/html/09/2d/44/092d441b80639f462fa667bb3f8964bf.html`(Color asis)
+- Applovin 预览工具: `https://p.applov.in/playablePreview?create=1`
+- Google AdMob 规范: `https://support.google.com/google-ads/answer/9981650?hl=en#_HTML`
+- Unity Ads 规范: `https://docs.unity.com/zh-cn/grow/acquire/creatives/playable/specifications`
+- Mintergal/Playturbo: `https://www.playturbo.com/review/doc`
+- Gemini 参考分析: `https://gemini.google.com/share/8340c20dd2d1`

+ 35 - 0
assets/css/loading.css

@@ -0,0 +1,35 @@
+/* Loading容器样式 */
+#loading-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #efefef;
+  z-index: 9999;
+  display: none;
+  justify-content: center;
+  align-items: center;
+  transition: opacity 0.3s;
+}
+
+/* 旋转动画样式 */
+.spinner {
+  width: 50px;
+  height: 50px;
+  border: 5px solid white;
+  border-top: 5px solid #3498db;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+/* 显示时样式 */
+.active {
+  display: flex !important;
+  opacity: 1;
+}

+ 472 - 0
assets/css/setting.css

@@ -0,0 +1,472 @@
+.action-sheet {
+  position:fixed;
+  bottom: -1000px;
+  right:0;
+  left: 0;
+  z-index: 1000;
+  background: white;
+  box-shadow: 0 2px 4px 0 rgba(0,0,0,0.10);
+  border-radius: 6px 6px 0 0;
+  transition: bottom 0.5s;
+  
+}
+
+.action-sheet img {
+  width: 24px;
+  height: 24px;
+}
+
+.action-sheet p {
+  line-height: 22px;
+  font-size: 15px;
+  font-weight: 600;
+  font-family: Arial, Helvetica, sans-serif;
+  color: #333333;
+  padding-left: 20px;
+}
+
+.setting {
+  display: flex;
+  padding-left: 5.3%;
+  /* padding-bottom: 2%; */
+}
+
+.setting.hint {
+  margin-top: 18px;
+  margin-bottom: 15px;
+}
+
+.hint-list {
+  width: 83%;
+  margin: 0 auto;
+  white-space: nowrap;  /*控制不要换行*/
+  overflow: auto;
+/* Firefox hide scrollbar*/
+  scrollbar-width: none;
+}
+
+/*webkit browser hide scrollbar*/
+.hint-list::-webkit-scrollbar { 
+  display: none;
+}
+
+
+.hint-item {
+  display: inline-block;
+  width: 34px;
+  height: 34px;
+  border: 2px solid #EFEFF4;
+  border-radius: 50%;
+  margin-left: 5px;
+}
+
+.line {
+  width: 89.4%;
+  height: 1px;
+  background: #EFEFF4;
+  margin: 0 auto;
+  margin-top: 8px;
+  margin-bottom: 8px;
+}
+
+.setting.autonext {
+  margin-bottom: 10px;
+}
+
+
+/* The switch - the box around the slider */
+.switch {
+  position: absolute;
+  display: inline-block;
+  width: 40px;
+  height: 22px;
+  right: 6%;
+  -webkit-tap-highlight-color:transparent;  /*消除移动端的灰色背景闪烁*/
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+
+/* The slider */
+.slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: #E5E5EA;
+  -webkit-transition: .4s;
+  transition: .4s;
+}
+
+.slider:before {
+  position: absolute;
+  content: "";
+  height: 20px;
+  width: 20px;
+  left: 1px;
+  bottom: 1px;
+  top: 1px;
+  background-color: white;
+  -webkit-transition: .4s;
+  transition: .4s;
+}
+
+input:checked + .slider {
+  background-color: #FF9500;
+}
+
+input:focus + .slider {
+  box-shadow: 0 0 1px #FF9500;
+}
+
+input:checked + .slider:before {
+  -webkit-transform: translateX(18px);
+  -ms-transform: translateX(18px);
+  transform: translateX(18px);
+}
+
+/* Rounded sliders */
+.slider.round {
+  border-radius: 10px;
+}
+
+.slider.round:before {
+  border-radius: 50%;
+}
+
+
+/**************************** AD 灯泡 *************************/
+.buld-circle-wrap {
+  position: absolute;
+  left: -100px;
+  top: 40%;
+  width: 50px;
+  height: 50px;
+  background: #C4C4C4;
+  z-index: 300;
+  /* background: transparent; */
+  border-radius: 50%;
+  /* border: 1px solid #cdcbd0; */
+  border: 1px solid transparent;
+  transition: left 0.5s;
+  animation: buld-scale 2s ease-in-out infinite;
+}
+
+@keyframes buld-scale{
+  0% { 
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  100% {
+    transform: scale(1);
+  }
+}
+
+@-webkit-keyframes buld-scale{
+  0% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  100% {
+    transform: scale(1);
+  }
+}
+
+
+.buld-circle-wrap .buld-circle .buld-mask,
+.buld-circle-wrap .buld-circle .buld-fill {
+  width: 50px;
+  height: 50px;
+  position: absolute;
+  border-radius: 50%;
+}
+
+.buld-mask .buld-fill {
+  clip: rect(0px, 25px, 50px, 0px);  /*rect (top, right, bottom, left) 左半边能看到 */
+  background-color: #1b9619;
+}
+
+.buld-circle-wrap .buld-circle .buld-mask {
+  clip: rect(0px, 50px, 50px, 25px); /*mask只能看到右半边*/
+}
+
+.buld-mask.buld-full,
+.buld-circle .buld-fill {
+  transition: transform 10s ease-in-out;
+  /* animation: buld-fill ease-in-out 10s;
+  transform: rotate(180deg); */
+}
+
+@keyframes buld-fill{
+  0% { 
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(180deg);
+  }
+}
+
+@-webkit-keyframes buld-fill{
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(180deg);
+  }
+}
+
+.buld-circle-wrap .buld-inside-circle {
+  position: absolute;
+  width: 42px;
+  height: 42px;
+  border-radius: 50%;
+  background: #EEB422;
+  line-height: 42px;
+  text-align: center;
+  margin-top: 4px;
+  margin-left: 4px;
+  color: black;
+  z-index: 500;
+  font-weight: 500;
+  font-size: 20px;
+  font-family: Arial, Helvetica, sans-serif;
+  box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.16);
+}
+
+.buld-wrapper {
+  position: absolute;
+  width: 78%;
+  height: 78%;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -45%);
+  z-index: 1000;
+  animation: buld-wrapper 2s ease-in-out infinite;
+}
+
+@keyframes buld-wrapper{
+  0% { 
+    top: 50%;
+  }
+  50% { 
+    top: 40%;
+  }
+  100% {
+    top: 50%;
+  }
+}
+
+@-webkit-keyframes buld-wrapper{
+  0% { 
+    top: 50%;
+  }
+  50% { 
+    top: 40%;
+  }
+  100% {
+    top: 50%;
+  }
+}
+
+.buld-bgimg {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+
+.buld-wrapper p {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -75%);
+  font-size: 15px;
+  font-weight: 600;
+  font-family: Arial, Helvetica, sans-serif;
+}
+
+.shinning-mask {
+  position: absolute;
+  width: 78%;
+  height: 78%;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  border-radius: 50%;
+  background: #EEB422;
+  z-index: 600;
+}
+
+/* 光线 */
+.shinning {
+  position: absolute;
+  left:50%;
+  top: 50%;
+  width: 30px;
+  height: 1px;
+  margin: 0 auto;
+  background: #fcff62;
+  transform-origin: 0%;
+  z-index: 500;
+  animation: shinning 2s infinite;
+}
+
+@keyframes shinning{
+  0% { 
+    opacity: 0;
+  }
+  50% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+@-webkit-keyframes shinning{
+  0% { 
+    opacity: 0;
+  }
+  50% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+#shinning2 {
+  transform: rotate(-20deg);
+}
+
+#shinning3 {
+  transform: rotate(-40deg);
+}
+
+#shinning4 {
+  transform: rotate(-60deg);
+}
+
+#shinning5 {
+  transform: rotate(-80deg);
+}
+
+#shinning6 {
+  transform: rotate(-100deg);
+}
+
+#shinning7 {
+  transform: rotate(-120deg);
+}
+
+#shinning8 {
+  transform: rotate(-140deg);
+}
+
+#shinning9 {
+  transform: rotate(-160deg);
+}
+
+#shinning10 {
+  transform: rotate(-180deg);
+}
+
+.adtext {
+  position: absolute;
+  color: darkgray;
+  left: 50%;
+  top: 100%;
+  margin-top: 3px;
+  transform: translate(-50%);
+  font-size: 12px;
+  font-weight: 600;
+  font-family: Arial, Helvetica, sans-serif;
+}
+
+.remind {
+  position: absolute;
+  display: none;
+  right: 20px;
+  top: 5%;
+  width: 40px;
+  height: 40px;
+  z-index: 150;
+  transform: scale(0.1);
+}
+
+.remind-bg {
+  width: 100%;
+  height: 100%;
+}
+
+.remind p {
+  position: absolute;
+  left: 50%;
+  top: 43%;
+  transform: translate(-50%, -50%);
+  font-size: 15px;
+  font-weight: 600;
+  font-family: Arial, Helvetica, sans-serif;
+}
+
+.remind.animation {
+  /* transform-origin: 0; */
+  animation: reminder 2s;
+}
+
+@keyframes reminder{
+  0% { 
+    right: 50%;
+    top: 50%;
+    transform: scale(3);
+  }
+  40% {
+    right: 50%;
+    top: 50%;
+    transform: scale(1.5);
+  }
+  65% {
+    right: 50%;
+    top: 50%;
+    transform: scale(1.5);
+  }
+  100% {
+    right: 20px;
+    top: 5%;
+    transform: scale(0.1);
+  }
+}
+
+@-webkit-keyframes reminder{
+  0% { 
+    right: 50%;
+    top: 50%;
+    transform: scale(3);
+  }
+  40% {
+    right: 50%;
+    top: 50%;
+    transform: scale(1.5);
+  }
+  65% {
+    right: 50%;
+    top: 50%;
+    transform: scale(1.5);
+  }
+  100% {
+    right: 20px;
+    top: 5%;
+    transform: scale(0.1);
+  }
+}
+
+

+ 597 - 0
assets/css/tools.css

@@ -0,0 +1,597 @@
+* {
+  padding: 0;
+  margin: 0;
+}
+
+html,
+body {
+  width: 100%;
+  height: 100%;
+  background: #fff9f2;
+  -webkit-tap-highlight-color: transparent; /*消除移动端的灰色背景闪烁*/
+  -webkit-touch-callout: none; /*系统默认菜单被禁用*/
+  -webkit-user-select: none; /*webkit浏览器*/
+  -khtml-user-select: none; /*早期浏览器*/
+  -moz-user-select: none; /*火狐*/
+  -ms-user-select: none; /*IE10*/
+  user-select: none;
+}
+
+input,
+textarea {
+  -webkit-user-select: auto; /*webkit浏览器*/
+  margin: 0px;
+  padding: 0px;
+  outline: none;
+}
+
+#container {
+  display: flex;
+  flex-direction: column;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100% !important;
+  width: 100%;
+}
+
+/* ── 根布局 ─────────────────────────────────────────── */
+/* 竖屏:game-area 全屏,sidebar fixed 叠在上方(logo 顶/CTA 底) */
+/* 横屏:game-area 左 58%,sidebar 右 42% 固定栏 */
+
+body {
+  position: relative;
+  overflow: hidden;
+}
+
+#game-area {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 0;
+  /* 与宣传屏背景一致,canvas 透明区及消失后风格统一 */
+  background: linear-gradient(160deg, #fff9f2 0%, #ffeedd 100%);
+}
+
+#canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+}
+
+/* ── 竖屏:sidebar 覆盖 canvas,logo 顶对齐,CTA 底对齐 ── */
+#sidebar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 100;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+  padding-bottom: 16px;
+  box-sizing: border-box;
+}
+
+/* ── 横屏:左右分栏 ─────────────────────────────────── */
+@media (orientation: landscape) {
+  #game-area {
+    width: 58%;
+    height: 100%;
+  }
+
+  #sidebar {
+    left: 58%;
+    top: 0;
+    width: 42%;
+    height: 100%;
+    background: linear-gradient(175deg, #fff0e6 0%, #ffdbb4 100%);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 28px;
+    padding: 24px 20px;
+    box-sizing: border-box;
+    pointer-events: auto;
+    box-shadow: -2px 0 16px rgba(0, 0, 0, 0.1);
+  }
+}
+
+#progress-toolbar {
+  height: 20px;
+}
+
+#toolbar-bottom {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  text-align: center;
+  z-index: 2;
+  pointer-events: none;
+  transition:
+    transform 1s ease-in-out,
+    opacity 1s ease-in-out;
+  padding-bottom: 72px; /* 竖屏:为底部 CTA 留空 */
+  box-sizing: border-box;
+}
+
+@media (orientation: landscape) {
+  #toolbar-bottom {
+    padding-bottom: 8px; /* 横屏:CTA 在右侧栏,不需要留底部空间 */
+  }
+}
+
+.hidden-toolbar-bottom {
+  transform: translateY(100%); /* 移出屏幕下方 */
+  opacity: 0; /* 完全透明 */
+}
+
+.hidden-toolbar-right {
+  transform: translateX(150%); /* 移出屏幕右方 */
+  opacity: 0; /* 完全透明 */
+}
+
+#color-btns {
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-start;
+  padding: 0px 10px 10px 10px;
+  overflow-x: scroll;
+  height: 60px;
+  align-items: center;
+  gap: 10px;
+  user-select: none;
+  /* 隐藏滚动条但保留滚动功能 */
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+}
+
+#color-btns::-webkit-scrollbar {
+  display: none;
+}
+
+.color-btn-container {
+  position: relative;
+  min-width: 48px;
+  width: 48px;
+  height: 48px;
+}
+
+.color-btn-container-selected {
+  transform: scale(1.2);
+}
+
+/* SVG 充满容器 */
+.color-btn-progress-ring {
+  width: 100%;
+  height: 100%;
+}
+
+/* 进度条轨道 */
+.color-btn-progress-ring-track {
+  transition: none;
+}
+
+/* 动态进度条 */
+.color-btn-progress-ring-value {
+  transition: stroke-dashoffset 0.5s ease-in-out;
+  transform: rotate(-90deg);
+  transform-origin: 50% 50%;
+}
+
+.color-btn {
+  position: absolute;
+  min-width: 40px;
+  width: 40px;
+  height: 40px;
+  line-height: 40px;
+  text-align: center;
+  border-radius: 50%;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 2;
+  box-sizing: border-box;
+  cursor: pointer;
+  pointer-events: auto; /*允许button接收事件*/
+  transition: all 0.2s;
+}
+
+#progress-wrapper {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+  width: 100%;
+  padding-left: 10px;
+  padding-right: 10px;
+  gap: 10px;
+}
+
+#progress-bar {
+  box-sizing: border-box;
+  flex-grow: 1;
+  overflow: hidden;
+  text-align: left;
+  background-color: lightgray;
+  border-radius: 2px;
+  height: 4px;
+}
+
+#progress {
+  transition: width 0.5s ease-in-out;
+  width: 0%;
+  height: 4px;
+  background-color: rgb(7, 206, 7);
+}
+
+#percent {
+  font-size: 12px;
+  color: rgb(7, 206, 7);
+  line-height: 12px;
+  font-weight: 500;
+  font-family: Arial, Helvetica, sans-serif;
+}
+
+/* 各种工具buuton */
+.btn-img {
+  width: 100%;
+  height: 100%;
+}
+
+.btn-img-mask {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0%;
+  border-radius: 50%;
+  opacity: 0;
+  background: black;
+}
+
+/* 有兼容性问题, 干脆不用了,直接js代码里监听各种事件来做吧*/
+@media (hover: hover) and (pointer: fine) {
+  .btn-img-mask:hover {
+    opacity: 0.5;
+  }
+}
+
+/* 移动端,解决按钮按下去再没复原的问题 */
+.btn-img-mask:active {
+  opacity: 0.5;
+}
+
+.btn {
+  z-index: 120;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.16);
+}
+
+/*adapt firefox*/
+@-moz-document url-prefix() {
+  .star-rating input:checked ~ label::before {
+    font-size: 36px;
+    line-height: 21px;
+  }
+}
+
+/*全部完成的撒花动画wrapper*/
+.finish-ani-wrapper {
+  position: absolute;
+  pointer-events: none;
+  width: 95vw;
+  height: 95vh;
+  z-index: 90;
+  top: 0%;
+}
+
+/*撒花动画的花片样式*/
+.finish-ani-div {
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  top: 0%;
+  opacity: 1;
+  z-index: 90;
+  transition:
+    top 3s,
+    left 3s,
+    opacity 3s;
+}
+
+/*爆破动画canvas*/
+.finish-ani-canvas {
+  position: fixed;
+  pointer-events: none;
+  bottom: 0px;
+  z-index: 300;
+}
+
+.toast {
+  position: fixed;
+  bottom: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(0, 0, 0, 0.8);
+  color: white;
+  padding: 12px 24px;
+  border-radius: 4px;
+  animation: fadeInOut 2.5s;
+  z-index: 9999;
+}
+.toast-hidden {
+  display: none;
+}
+
+@keyframes fadeInOut {
+  0% {
+    opacity: 0;
+    bottom: -20px;
+  }
+  20% {
+    opacity: 1;
+    bottom: 20px;
+  }
+  80% {
+    opacity: 1;
+    bottom: 20px;
+  }
+  100% {
+    opacity: 0;
+    bottom: -20px;
+  }
+}
+
+/* ── Canvas 缩小消失动画 ────────────────────────────── */
+#canvas.canvas-shrink-out {
+  transition:
+    transform 0.55s ease-in,
+    opacity 0.55s ease-in;
+  transform: scale(0.05);
+  opacity: 0;
+  pointer-events: none;
+}
+
+/* ── Logo 栏 ────────────────────────────────────────── */
+/* 竖屏:sidebar 顶部,图标+文字横向排列 */
+/* 横屏:右侧栏,图标+文字纵向排列,更大 */
+#app-logo-bar {
+  width: 100%;
+  display: flex;
+  flex-direction: row; /* 竖屏:横向 */
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  padding: 14px 16px 0;
+  box-sizing: border-box;
+  pointer-events: none;
+  flex-shrink: 0;
+}
+
+#app-logo-bar.visible {
+  opacity: 1;
+}
+
+#app-logo {
+  height: 52px;
+  width: auto;
+  display: block;
+  flex-shrink: 0;
+}
+
+#app-logo-txt {
+  height: 40px;
+  width: auto;
+  max-width: 55%;
+  display: block;
+}
+
+@media (orientation: landscape) {
+  #app-logo-bar {
+    flex-direction: column; /* 横屏:纵向 */
+    gap: 10px;
+    padding: 0;
+    width: 100%;
+  }
+
+  #app-logo {
+    height: clamp(64px, 17vh, 110px);
+    max-width: 70%;
+    object-fit: contain;
+  }
+
+  #app-logo-txt {
+    height: clamp(28px, 8vh, 48px);
+    max-width: 88%;
+    object-fit: contain;
+  }
+}
+
+/* ── 广告标识 ────────────────────────────────────────── */
+#ad-badge {
+  position: fixed;
+  top: 6px;
+  right: 8px;
+  z-index: 300;
+  background: rgba(0, 0, 0, 0.35);
+  color: #fff;
+  font-size: 10px;
+  font-family: Arial, Helvetica, sans-serif;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  pointer-events: none;
+}
+
+/* ── CTA 按钮容器 ────────────────────────────────────── */
+/* 竖屏:sidebar flex 末尾(space-between 自动贴底) */
+/* 横屏:sidebar flex 内静态排列 */
+#cta-btn-wrapper {
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  pointer-events: none;
+  flex-shrink: 0;
+}
+
+@media (orientation: landscape) {
+  #cta-btn-wrapper {
+    width: 100%;
+  }
+
+  #cta-btn {
+    width: 100%;
+    max-width: none;
+    height: 52px;
+    font-size: 16px;
+  }
+}
+
+#cta-btn {
+  pointer-events: auto;
+  width: min(300px, 80vw);
+  height: 44px;
+  border: none;
+  border-radius: 22px;
+  background: linear-gradient(135deg, #ff5f1f 0%, #ffb300 100%);
+  color: #fff;
+  font-size: 18px;
+  font-weight: 800;
+  letter-spacing: 3px;
+  font-family: Arial, Helvetica, sans-serif;
+  cursor: pointer;
+  /* 立体感阴影 */
+  box-shadow:
+    0 4px 10px rgba(255, 95, 31, 0.45),
+    0 2px 0 rgba(255, 255, 255, 0.25) inset,
+    0 -3px 0 rgba(0, 0, 0, 0.18) inset;
+  /* 按压效果 */
+  transition:
+    transform 0.1s ease,
+    box-shadow 0.1s ease;
+  /* 脉冲光晕动画 */
+  animation: cta-pulse 2s ease-in-out infinite;
+}
+
+#cta-btn:active {
+  transform: scale(0.96) translateY(2px);
+  box-shadow:
+    0 2px 8px rgba(255, 95, 31, 0.4),
+    0 1px 0 rgba(255, 255, 255, 0.2) inset;
+}
+
+@keyframes cta-pulse {
+  0%,
+  100% {
+    box-shadow:
+      0 4px 10px rgba(255, 95, 31, 0.45),
+      0 2px 0 rgba(255, 255, 255, 0.25) inset,
+      0 -3px 0 rgba(0, 0, 0, 0.18) inset;
+  }
+  50% {
+    box-shadow:
+      0 4px 16px rgba(255, 95, 31, 0.7),
+      0 2px 0 rgba(255, 255, 255, 0.25) inset,
+      0 -3px 0 rgba(0, 0, 0, 0.18) inset;
+  }
+}
+
+/* 填色完成后的强化 CTA 状态 */
+@keyframes cta-highlight-pulse {
+  0%,
+  100% {
+    transform: scale(1.08);
+    box-shadow:
+      0 6px 24px rgba(255, 95, 31, 0.8),
+      0 2px 0 rgba(255, 255, 255, 0.3) inset,
+      0 -3px 0 rgba(0, 0, 0, 0.2) inset;
+  }
+  50% {
+    transform: scale(1.14);
+    box-shadow:
+      0 8px 32px rgba(255, 95, 31, 1),
+      0 2px 0 rgba(255, 255, 255, 0.3) inset,
+      0 -3px 0 rgba(0, 0, 0, 0.2) inset;
+  }
+}
+
+#cta-btn.cta-highlight {
+  animation: cta-highlight-pulse 0.8s ease-in-out infinite;
+}
+
+/* ── 宣传界面 ──────────────────────────────────────── */
+/* 竖屏/横屏均在 game-area 内 absolute 展开:
+   竖屏:game-area=全屏,promo 覆盖全屏;sidebar logo/CTA 在 z:100 叠在上方
+   横屏:game-area=左58%,promo 填满左侧 */
+#promo-screen {
+  position: absolute;
+  inset: 0;
+  display: none;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 20px;
+  background: linear-gradient(160deg, #fff9f2 0%, #ffeedd 100%);
+  z-index: 10;
+  /* 竖屏:顶部让出 logo(~80px),底部让出 CTA(~72px) */
+  padding: 80px 2vw 72px;
+  box-sizing: border-box;
+}
+
+#promo-screen.visible {
+  display: flex;
+}
+
+@media (orientation: landscape) {
+  #promo-screen {
+    /* 横屏:logo/CTA 在右侧栏,无需额外留白 */
+    padding: 16px 12px;
+    gap: 16px;
+  }
+}
+
+/* coloring-pages:从小(远)到大(近) */
+#promo-coloring {
+  max-width: 96%;
+  max-height: 52vh;
+  object-fit: contain;
+  opacity: 0;
+  transform: scale(0.12);
+  transition:
+    transform 0.7s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.05s,
+    opacity 0.4s ease 0.05s;
+}
+#promo-coloring.animate-in {
+  transform: scale(1);
+  opacity: 1;
+}
+
+/* slogan:从大(近)到小(正常),比 coloring-pages 晚 0.25s */
+#promo-slogon {
+  max-width: 95%;
+  max-height: 20vh;
+  object-fit: contain;
+  opacity: 0;
+  transform: scale(1.9);
+  transition:
+    transform 0.65s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.3s,
+    opacity 0.35s ease 0.3s;
+}
+#promo-slogon.animate-in {
+  transform: scale(1);
+  opacity: 1;
+}

BIN
assets/fonts/numbers_roboto_500.png


BIN
assets/img/coloring-pages.png


BIN
assets/img/finger.png


BIN
assets/img/finger2.png


BIN
assets/img/logo-txt.png


BIN
assets/img/logo.png


BIN
assets/img/slogon.png


+ 1 - 0
assets/res/6a18f7d9957ac783bad75479/config.json

@@ -0,0 +1 @@
+[{"areas":[{"id":4281899224,"center":{"x":64.5,"y":64.5,"radius":64.14},"rect":{"x":0,"y":0,"width":500,"height":500},"count":119927}],"color":4289523686},{"areas":[{"id":4289251464,"center":{"x":307.5,"y":110.5,"radius":80.7},"rect":{"x":88,"y":26,"width":384,"height":249},"count":47590}],"color":4282401471},{"areas":[{"id":4280817768,"center":{"x":155.5,"y":161.5,"radius":12.5},"rect":{"x":108,"y":147,"width":335,"height":118},"count":10582}],"color":4286688470},{"areas":[{"id":4294482136,"center":{"x":299.5,"y":362.5,"radius":37.91},"rect":{"x":241,"y":206,"width":104,"height":229},"count":13850}],"color":4289383404},{"areas":[{"id":4294477976,"center":{"x":197.5,"y":279.5,"radius":37.64},"rect":{"x":75,"y":159,"width":200,"height":194},"count":19552}],"color":4293193471},{"areas":[{"id":4292348152,"center":{"x":239.5,"y":425.5,"radius":21.71},"rect":{"x":115,"y":343,"width":174,"height":121},"count":12476}],"color":4292206577},{"areas":[{"id":4285011992,"center":{"x":214.5,"y":353.5,"radius":6.91},"rect":{"x":167,"y":344,"width":81,"height":29},"count":1275}],"color":4291205776},{"areas":[{"id":4283992312,"center":{"x":292.5,"y":461.5,"radius":22.5},"rect":{"x":40,"y":352,"width":402,"height":140},"count":24748}],"color":4285771662}]

BIN
assets/res/6a18f7d9957ac783bad75479/map.png


BIN
assets/res/6a18f7d9957ac783bad75479/page.png


BIN
assets/res/6a18f7d9957ac783bad75479/special.jpeg


BIN
assets/sound/color_done_02.mp3


BIN
assets/sound/section_done.mp3


BIN
assets/sound/sound_hint.mp3


Файловите разлики са ограничени, защото са твърде много
+ 11 - 0
dist/index.html


+ 68 - 0
index.html

@@ -0,0 +1,68 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <title>Coloring Page Paint On Line | Coloring Game</title>
+
+    <script type="module" src="/src/filler/index.ts"></script>
+    <meta
+      name="viewport"
+      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
+    />
+
+    <link rel="stylesheet" href="/assets/css/tools.css" type="text/css" />
+    <link rel="stylesheet" href="/assets/css/setting.css" type="text/css" />
+    <link rel="stylesheet" href="/assets/css/loading.css" type="text/css" />
+  </head>
+
+  <body>
+    <div id="loading-overlay">
+      <div class="spinner"></div>
+    </div>
+
+    <!-- 广告标识(各平台合规要求) -->
+    <div id="ad-badge">Ad</div>
+
+    <!-- 填色区域(左侧 / 竖屏全屏);promo 也在此覆盖 -->
+    <div id="game-area">
+      <canvas id="canvas"></canvas>
+
+      <!-- 进度条 + 调色板,绝对定位在底部 -->
+      <div id="toolbar-bottom">
+        <div id="progress-toolbar">
+          <div id="progress-wrapper" class="progress-wrapper">
+            <div id="progress-bar" class="progress-bar">
+              <div id="progress" class="progress"></div>
+            </div>
+            <div id="percent" class="percent"></div>
+          </div>
+        </div>
+        <div id="color-btns"></div>
+      </div>
+
+      <!-- 宣传界面:canvas 消失后填充此区域
+           竖屏=覆盖全屏;横屏=覆盖左侧 58% -->
+      <div id="promo-screen">
+        <img id="promo-coloring" alt="" />
+        <img id="promo-slogon" alt="" />
+      </div>
+    </div>
+
+    <!-- 竖屏:fixed 叠加(logo 顶 / CTA 底)
+         横屏:右侧固定栏 -->
+    <div id="sidebar">
+      <div id="app-logo-bar">
+        <!-- logo.png = 图标,logo-txt.png = 文字;竖屏横排,横屏竖排 -->
+        <img id="app-logo" alt="" />
+        <img id="app-logo-txt" alt="" />
+      </div>
+
+      <!-- CTA 按钮 -->
+      <div id="cta-btn-wrapper">
+        <button id="cta-btn">PLAY NOW</button>
+      </div>
+    </div>
+
+    <div id="toast" class="toast toast-hidden"></div>
+  </body>
+</html>

+ 1080 - 0
package-lock.json

@@ -0,0 +1,1080 @@
+{
+  "name": "test",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "test",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "js-confetti": "^0.12.0"
+      },
+      "devDependencies": {
+        "typescript": "^5.5.4",
+        "vite": "^5.4.2",
+        "vite-plugin-singlefile": "^2.3.3"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+      "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+      "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+      "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+      "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+      "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+      "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+      "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+      "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+      "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+      "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+      "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+      "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+      "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+      "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+      "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+      "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+      "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+      "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+      "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+      "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+      "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+      "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+      "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/js-confetti": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/js-confetti/-/js-confetti-0.12.0.tgz",
+      "integrity": "sha512-1R0Akxn3Zn82pMqW65N1V2NwKkZJ75bvBN/VAb36Ya0YHwbaSiAJZVRr/19HBxH/O8x2x01UFAbYI18VqlDN6g=="
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.7",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+      "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
+      "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
+      "dev": true
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.4.44",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz",
+      "integrity": "sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.7",
+        "picocolors": "^1.0.1",
+        "source-map-js": "^1.2.0"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+      "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.4",
+        "@rollup/rollup-android-arm64": "4.60.4",
+        "@rollup/rollup-darwin-arm64": "4.60.4",
+        "@rollup/rollup-darwin-x64": "4.60.4",
+        "@rollup/rollup-freebsd-arm64": "4.60.4",
+        "@rollup/rollup-freebsd-x64": "4.60.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+        "@rollup/rollup-linux-arm64-musl": "4.60.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+        "@rollup/rollup-linux-loong64-musl": "4.60.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-musl": "4.60.4",
+        "@rollup/rollup-openbsd-x64": "4.60.4",
+        "@rollup/rollup-openharmony-arm64": "4.60.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+        "@rollup/rollup-win32-x64-gnu": "4.60.4",
+        "@rollup/rollup-win32-x64-msvc": "4.60.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+      "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.5.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+      "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-singlefile": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
+      "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">18.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^4.59.0",
+        "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "test",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "dev": "npx vite --open --cors --host 0.0.0.0",
+    "build": "npx vite build"
+  },
+  "author": "",
+  "license": "ISC",
+  "devDependencies": {
+    "typescript": "^5.5.4",
+    "vite": "^5.4.2",
+    "vite-plugin-singlefile": "^2.3.3"
+  },
+  "dependencies": {
+    "js-confetti": "^0.12.0"
+  }
+}

+ 183 - 0
share.html

@@ -0,0 +1,183 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta http-equiv="x-ua-compatible" content="ie=edge">
+  <title>Art Color</title>
+
+  <script type="module" src="/src/filler/play.ts"></script>
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="Free Coloring Pages Paint Online!">
+  <meta name="keywords" content="color, paint, app, ios, android, game">
+
+  <meta property="og:site_name" content="art.pcoloring.com">
+  <meta property="og:title" content="Art Number Coloring">
+  <meta property="og:description" content="Free Coloring Pages Paint Online!">
+  <meta property="og:image" content="https://d2mb6s2cy1zg97.cloudfront.net/thumbs/coloring-page/work/480/686240183deb2d264e3a39d9.webp">
+  <meta property="og:type" content="website">
+
+  <!-- MARK: Universal Link / Android App Link 的核心配置 -->
+  <!-- 这些 meta 标签的值应该是完整的 HTTPS 链接,Facebook 会识别并尝试拉起 App -->
+  <meta property="og:url" content="https://art.pcoloring.com/share/686240183deb2d264e3a39d9" />
+  <!-- **Universal Link 路径** -->
+  <meta property="al:ios:url" content="https://art.pcoloring.com/share/686240183deb2d264e3a39d9" />
+  <!-- **Universal Link 路径** -->
+  <meta property="al:ios:app_store_id" content="1575480118" /> <!-- **iOS App Store ID** -->
+  <meta property="al:ios:app_name" content="Art Number Coloring Book" /> <!-- **iOS 应用名称** -->
+
+  <meta property="al:android:package" content="com.pcoloring.art.puzzle.color.by.number" /> <!-- **Android 包名** -->
+  <meta property="al:android:url" content="https://art.pcoloring.com/share/686240183deb2d264e3a39d9" />
+  <!-- ** Universal Link 路径** -->
+  <meta property="al:android:app_name" content="Art Number Coloring Book" /> <!-- **Android 应用名称** -->
+
+  <meta name="apple-itunes-app" content="app-id=1575480118">
+
+  <link rel="icon" href="/assets/icon/favicon.ico" type="image/x-icon">
+  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icon/icon.png">
+
+  <style>
+    :root {
+      --primary-color: #ff6b6b;
+      --secondary-color: #4ecdc4;
+      --accent-color: #ffd166;
+      --background-color: #f9f9f9;
+      --text-color: #333;
+      --light-text: #666;
+      --border-color: #e0e0e0;
+    }
+    
+    body {
+      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+      display: flex;
+      flex-direction: column; /* 垂直方向排列子元素 */
+      justify-content: center; /* 垂直居中 */
+      align-items: center;     /* 水平居中 */
+      min-height: 100vh;       /* 最小高度为视口高度,确保垂直居中 */
+      margin: 0;
+      padding: 20px;
+      box-sizing: border-box; /* 盒模型为边框盒 */
+      background-color: var(--background-color);
+      color: var(--text-color);
+      line-height: 1.6;
+
+    }
+
+    /* 父容器,用于包裹图片和 Canvas,并使其相对定位 */
+    .image-canvas-container {
+      position: relative;
+      max-width: 90%; /* 限制容器最大宽度 */
+      height: auto;
+      display: inline-block; /* 确保容器根据图片尺寸收缩 */
+      box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); /* 添加阴影效果 */
+      border-radius: 12px; /* 圆角 */
+      overflow: hidden; /* 隐藏超出容器的内容 */
+    }
+
+    /* 图片样式:使其响应式并填充容器 */
+    .image-canvas-container img {
+      position: relative;
+      display: block; /* 移除图片底部空白 */
+      max-width: 100%; /* 最大宽度为父容器的100% */
+      height: auto;    /* 高度自动调整,保持图片比例 */
+      border-radius: 12px; /* 与容器相同的圆角 */
+      z-index: 100;
+    }
+
+    /* Canvas 样式:绝对定位,与图片完全重叠 */
+    .image-canvas-container canvas {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      border-radius: 12px; /* 与容器相同的圆角 */
+      z-index: 50;
+    }
+
+    .btn {
+      display: inline-block;
+      background-color: var(--primary-color);
+      color: white;
+      border: none;
+      padding: 10px 20px;
+      border-radius: 5px;
+      font-size: 1.1rem;
+      font-weight: 500;
+      cursor: pointer;
+      transition: background-color 0.3s ease;
+      text-decoration: none;
+      text-align: center;
+      margin-right: 10px;
+      margin-bottom: 10px;
+    }
+
+    .btn:hover {
+      background-color: #ff4d4d;
+    }
+
+    .btn-secondary {
+      background-color: var(--secondary-color);
+    }
+
+    .btn-secondary:hover {
+      background-color: #37b0a8;
+    }
+
+    .play-button {
+      position: absolute;
+      display: block;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      width: 80px;
+      height: 80px;
+      background-color: rgba(255, 107, 107, 0.8);
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: all 0.3s ease;
+      z-index: 0;
+    }
+
+    .play-button:hover {
+      background-color: rgba(255, 107, 107, 1);
+      transform: translate(-50%, -50%) scale(1.1);
+    }
+
+    .play-button::after {
+      content: "";
+      width: 0;
+      height: 0;
+      border-top: 15px solid transparent;
+      border-bottom: 15px solid transparent;
+      border-left: 25px solid white;
+      margin-left: 5px;
+    }
+
+    .buttons {
+      width: 100%;
+      max-width: 550px;
+      display: flex;
+      flex-wrap: wrap;
+      justify-content: space-between;
+      align-items: center;
+      margin-top: 40px;
+    }
+  </style>
+</head>
+
+<body>
+  <div class="image-canvas-container">
+    <img id="poster-img" src="https://d2mb6s2cy1zg97.cloudfront.net/thumbs/coloring-page/work/480/686240183deb2d264e3a39d9.webp" alt="Art Number Coloring" />
+    <canvas id="canvas"></canvas>
+    <div id="play-button" class="play-button"></div>
+  </div>
+  <div class="buttons">
+    <a href="/">Home</a>
+    <a href="#>" class="btn btn-secondary">Download App</a>
+    <a href="#">Detail>></a>
+  </div>
+</body>
+
+</html>

+ 164 - 0
src/base/2d.ts

@@ -0,0 +1,164 @@
+
+
+
+/**
+ * 二维点
+ */
+export class Point {
+  constructor(
+    public readonly x: number,
+    public readonly y: number
+  ) { }
+}
+
+
+
+export class Rect {
+
+  constructor(
+    public readonly x: number,
+    public readonly y: number,
+    public readonly width: number,
+    public readonly height: number
+  ) { }
+
+  get center(): Point {
+    return new Point(this.x + this.width / 2, this.y + this.height / 2);
+  }
+
+
+  public static fromCenter(center: Point, width: number, height: number) {
+    return new Rect(center.x - width / 2, center.y - height / 2, width, height);
+  }
+
+
+  toString(): string {
+    return `Rect(${this.x}, ${this.y},${this.width}, ${this.height})`;
+  }
+
+
+  vertex(): Array<number> {
+    var x1 = this.x;
+    var x2 = this.x + this.width;
+    var y1 = this.y;
+    var y2 = this.y + this.height;
+
+    return [
+      x1, y1,
+      x2, y1,
+      x1, y2,
+      x1, y2,
+      x2, y1,
+      x2, y2,
+    ]
+  }
+
+  coverRadius(x: number, y: number): number {
+    var mx, my;
+    if (x > (this.x + this.width / 2)) mx = this.x
+    else mx = this.x + this.width
+  
+    if (y > (this.y + this.height / 2)) my = this.y
+    else my = this.y + this.height
+  
+    var dx = x - mx
+    var dy = y - my
+    return Math.sqrt(dx * dx + dy * dy)
+  }
+
+
+
+  centerFitTo(rect : Rect) : Rect {
+    const scale = Math.min(rect.width /this.width, rect.height / this.height)
+    const width = scale * this.width
+    const height = scale * this.height
+    const dx =  rect.width / 2 - width / 2
+    const dy = rect.height / 2 - height /2
+    return new Rect(rect.x + dx, rect.y + dy, width, height)
+  }
+
+
+}
+
+
+
+export function coverRadius(rect: Rect, x: number, y: number) {
+  var mx, my;
+  if (x > (rect.x + rect.width / 2)) mx = rect.x
+  else mx = rect.x + rect.width
+
+  if (y > (rect.y + rect.height / 2)) my = rect.y
+  else my = rect.y + rect.height
+
+  var dx = x - mx
+  var dy = y - my
+  return Math.sqrt(dx * dx + dy * dy)
+}
+
+
+
+export function rectangle(x: number, y: number, w: number, h: number): Array<number> {
+  return new Rect(x, y, w, h).vertex()
+}
+
+
+
+
+export function distance(x1: number, y1: number, x2: number, y2: number): number {
+  let dx = x1 - x2
+  let dy = y1 - y2
+  return Math.sqrt(dx * dx + dy * dy)
+}
+
+
+
+
+
+export function fillRectangle(array: Float32Array, offset: number, x: number, y: number, width: number, height: number) {
+
+  array[offset] = x
+  array[offset + 1] = y
+
+  array[offset + 2] = x + width
+  array[offset + 3] = y
+
+  array[offset + 4] = x
+  array[offset + 5] = y + height
+
+  array[offset + 6] = x
+  array[offset + 7] = y + height
+
+  array[offset + 8] = x + width
+  array[offset + 9] = y
+
+  array[offset + 10] = x + width
+  array[offset + 11] = y + height
+}
+
+
+
+
+export function rectangleArray(x: number, y: number, width: number, height: number): Float32Array {
+  const array = new Float32Array(12)
+  const offset = 0
+
+  array[offset] = x
+  array[offset + 1] = y
+
+  array[offset + 2] = x + width
+  array[offset + 3] = y
+
+  array[offset + 4] = x
+  array[offset + 5] = y + height
+
+  array[offset + 6] = x
+  array[offset + 7] = y + height
+
+  array[offset + 8] = x + width
+  array[offset + 9] = y
+
+  array[offset + 10] = x + width
+  array[offset + 11] = y + height
+
+  return array
+}

+ 88 - 0
src/base/Animator.ts

@@ -0,0 +1,88 @@
+
+
+export interface Interpolator {
+  getInterpolation(t: number): number;
+}
+
+
+export type AnimatorCallback = (animator : Animator) => void
+
+
+export class Animator {
+
+
+  private startTime: number = new Date().getTime()
+  private _progress: number = 0
+  public get progress() {
+    return this._progress
+  }
+
+  private running: boolean = true
+  private ended: boolean = false
+  private canceled: boolean = false
+
+  constructor(
+    private duration: number,
+    private onUpdate: AnimatorCallback,
+    private onEnd: AnimatorCallback,
+    private interpolator?: Interpolator
+  ) {
+
+  }
+
+  update() {
+    if (this.removable()) return
+
+    const dur = new Date().getTime() - this.startTime
+    if (dur < this.duration) {
+        this._progress = dur / this.duration
+        this.onUpdate(this)
+    } else if (dur >= this.duration) {
+        if (this.running) {
+            this._progress = 1
+            this.onUpdate(this)
+            this.running = false
+        } else {
+            this.end()
+        }
+    }
+}
+
+
+
+
+  removable(): Boolean {
+    return this.ended || this.canceled
+  }
+
+
+  cancel() {
+    if (!this.canceled) {
+      this.canceled = true
+      this.end()
+    }
+  }
+
+
+
+  end() {
+    if (!this.ended) {
+      this.onEnd(this)
+      this.ended = true
+    }
+  }
+
+
+  value(): number {
+    if (this.interpolator != null) {
+      return this.interpolator.getInterpolation(this._progress)
+    } else {
+      return this._progress
+    }
+  }
+
+
+
+
+
+}

+ 144 - 0
src/base/BgLayer.ts

@@ -0,0 +1,144 @@
+import { createPatternTexture, createTexture, TexImage } from "../filler/common";
+import { fillRectangle, Rect } from "./2d"
+import { LayerAB, Scene } from "./Scene"
+import { createProgram, createShader } from "./utils"
+
+
+export enum BgType { FitCenter, Repeat }
+
+
+export class BgLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_image;
+    varying vec2 v_texCoord;
+
+    void main() {
+      vec4 color = texture2D(u_image, v_texCoord);
+      gl_FragColor = color;
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  uMatrixLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+  texcoordBuffer: WebGLBuffer
+
+  vertexArray = new Float32Array(12)
+  texCoordArray = new Float32Array(12)
+
+  texture: WebGLTexture
+
+
+  constructor(
+    public readonly scene: Scene,
+    private image: TexImage,
+    public readonly width: number,
+    public readonly height: number,
+    bgType: BgType = BgType.FitCenter,
+    private texWidth: number = image.width,
+    private texHeight: number = image.height,
+  ) {
+    super()
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+
+
+    fillRectangle(this.vertexArray, 0, 0, 0, width, height)
+
+
+    if (bgType == BgType.FitCenter) {
+      const viewPort = new Rect(0, 0, width, height)
+      const texRect = new Rect(0, 0, texWidth, texHeight)
+      const dest = viewPort.centerFitTo(texRect)
+      fillRectangle(this.texCoordArray, 0, dest.x / texWidth, dest.y / texHeight, dest.width / texWidth, dest.height / texHeight)
+    } else {
+      fillRectangle(this.texCoordArray, 0, 0, 0, width / texWidth, height / texHeight)
+    }
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+
+    this.texcoordBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW);
+
+
+    this.texture = gl.createTexture()!;
+    gl.bindTexture(gl.TEXTURE_2D, this.texture);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+  }
+
+
+
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.projectionMat)
+
+
+    gl.activeTexture(gl.TEXTURE0)
+    gl.bindTexture(gl.TEXTURE_2D, this.texture)
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+  }
+
+
+
+
+}
+
+
+
+

+ 106 - 0
src/base/BorderLayer.ts

@@ -0,0 +1,106 @@
+import { Color } from "../filler/common";
+import { LayerAB, Scene } from "./Scene";
+import { createProgram, createShader } from "./utils";
+
+
+export class BorderLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    uniform mat4 u_matrix;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform vec4 u_color;
+
+    void main() {
+      gl_FragColor = u_color;
+    }
+  
+  `
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uColorLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+
+  vertexArray: Float32Array
+
+  colorArray: Float32Array
+
+
+  constructor(
+    private scene: Scene,
+    private x: number,
+    private y: number,
+    private width: number,
+    private height: number,
+    private color: number = 0xffff0000,
+    private lineWidth: number = 1,
+  ) {
+    super()
+
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+    this.uColorLoc = gl.getUniformLocation(this.program, "u_color")!
+
+
+    //fillRectangle(this.vertexArray, 0, x, y, width, height )
+
+    this.vertexArray = new Float32Array([
+      x, y,
+      x, y + height,
+      x + width, y + height,
+      x + width, y
+    ])
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+    this.colorArray = new Color(color).toFloatArray()
+
+
+  }
+
+
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+    gl.uniform4fv(this.uColorLoc, this.colorArray)
+
+    gl.lineWidth(this.lineWidth)
+    gl.drawArrays(gl.LINE_LOOP, 0, 4);
+
+  }
+
+
+}

+ 106 - 0
src/base/BoxLayer.ts

@@ -0,0 +1,106 @@
+import { Color } from "../filler/common";
+import { fillRectangle } from "./2d"
+import { LayerAB, Scene } from "./Scene"
+import { createProgram, createShader } from "./utils"
+
+
+
+
+export class BoxLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    uniform mat4 u_matrix;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform vec4 u_color;
+
+    void main() {
+      gl_FragColor = u_color;
+    }
+  
+  `
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uColorLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+
+  vertexArray = new Float32Array(12)
+
+  colorArray: Float32Array
+
+  constructor(
+    public readonly scene: Scene,
+    public readonly x: number,
+    public readonly y: number,
+    public readonly width: number,
+    public readonly height: number,
+    public readonly color: number = 0xffffffff,  //abgr
+    private fragmentShader: string | null = null
+  ) {
+    super()
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, fragmentShader || this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+    this.uColorLoc = gl.getUniformLocation(this.program, "u_color")!
+
+
+    fillRectangle(this.vertexArray, 0, x, y, width, height)
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+    this.colorArray = new Color(color).toFloatArray()
+
+  }
+
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+    gl.uniform4fv(this.uColorLoc, this.colorArray)
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+  }
+
+
+
+  toString(): string {
+    return `BoxLayer()`
+  }
+
+}
+
+
+
+

+ 114 - 0
src/base/DebugLayer.ts

@@ -0,0 +1,114 @@
+import { fillRectangle} from "./2d"
+import { LayerAB, Scene } from "./Scene"
+import { createProgram, createShader } from "./utils"
+
+
+
+
+export class DebugLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    uniform mat4 u_matrix;
+
+    void main() {
+
+      gl_PointSize = 20.0;
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    varying vec2 v_texCoord;
+
+    void main() {
+      gl_FragColor = vec4(0, 0, 1, 1);
+    }
+  
+  `
+
+  
+
+
+  program: WebGLProgram
+
+  aPositionLoc: number
+  uMatrixLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+
+  vertexArray = new Float32Array(12)
+
+  constructor(
+    public readonly scene: Scene,
+  ) {
+    super()
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+
+
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+  }
+
+
+  
+
+
+
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+
+    gl.drawArrays(gl.POINTS, 0, 1);
+
+  }
+
+
+  override tap(cx: number, cy: number, sx: number, sy: number): void {
+    console.log(`tap @${cx},${cy}`)
+
+
+    const gl = this.scene.gl
+
+    this.vertexArray[0] = cx; 
+    this.vertexArray[1] = cy;
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+    this.scene.invalidate()
+
+  }
+  
+
+  toString(): string {
+    return `DebugLayer()`
+  }
+
+}
+
+
+
+

+ 112 - 0
src/base/FrameLayer.ts

@@ -0,0 +1,112 @@
+import { Color } from "../filler/common";
+import { LayerAB, Scene } from "./Scene";
+import { createProgram, createShader } from "./utils";
+
+export class FrameLayer extends LayerAB {
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    uniform mat4 u_matrix;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+    }
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform vec4 u_color;
+
+    void main() {
+      gl_FragColor = u_color;
+    }
+  `;
+
+  program: WebGLProgram;
+
+  aPositionLoc: number;
+  uMatrixLoc: WebGLUniformLocation;
+  uColorLoc: WebGLUniformLocation;
+
+  vertexBuffer: WebGLBuffer;
+
+  vertexArray: Float32Array;
+
+  colorArray: Float32Array;
+
+  constructor(
+    private scene: Scene,
+    private x: number,
+    private y: number,
+    private width: number,
+    private height: number,
+    private color: number = 0xffff0000,
+    private lineWidth: number = 30
+  ) {
+    super();
+
+    const gl = scene.gl;
+
+    this.program = createProgram(
+      gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!
+    )!;
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position");
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!;
+    this.uColorLoc = gl.getUniformLocation(this.program, "u_color")!;
+
+    this.vertexArray = this.generateThickBorderVertices(x, y, width, height, lineWidth);
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+    this.colorArray = new Color(color).toFloatArray();
+  }
+
+  private generateThickBorderVertices(x: number, y: number, width: number, height: number, lineWidth: number): Float32Array {
+    const halfLineWidth = lineWidth / 2;
+    return new Float32Array([
+      // 上边框
+      x - halfLineWidth, y - halfLineWidth,
+      x + width + halfLineWidth, y - halfLineWidth,
+      x - halfLineWidth, y + halfLineWidth,
+      x + width + halfLineWidth, y + halfLineWidth,
+
+      // 右边框
+      x + width - halfLineWidth, y - halfLineWidth,
+      x + width + halfLineWidth, y - halfLineWidth,
+      x + width - halfLineWidth, y + height + halfLineWidth,
+      x + width + halfLineWidth, y + height + halfLineWidth,
+
+      // 下边框
+      x + width + halfLineWidth, y + height - halfLineWidth,
+      x - halfLineWidth, y + height - halfLineWidth,
+      x + width + halfLineWidth, y + height + halfLineWidth,
+      x - halfLineWidth, y + height + halfLineWidth,
+
+      // 左边框
+      x + halfLineWidth, y + height,
+      x - halfLineWidth, y + height,
+      x + halfLineWidth, y,
+      x - halfLineWidth, y,
+    ]);
+  }
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix);
+    gl.uniform4fv(this.uColorLoc, this.colorArray);
+
+    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 16);
+  }
+}

+ 247 - 0
src/base/Gesture.ts

@@ -0,0 +1,247 @@
+
+type DragCallback = (dx: number, dy: number) => void
+type ZoomCallback = (scale: number, focusX: number, focusY: number) => void
+type TapCallback = (dx: number, dy: number) => void
+
+export interface Callbacks {
+  drag?: DragCallback,
+  zoom?: ZoomCallback,
+  tap?: TapCallback,
+}
+
+
+
+interface MyTouch {
+  identifier: number,
+  lastX: number,
+  lastY: number,
+  dx: number,
+  dy: number,
+}
+
+interface ScaleTracker {
+  focusX: number,
+  focusY: number,
+  distance: number,
+}
+
+export class TouchTracker {
+  touches: Array<MyTouch> = []
+  callbacks: Callbacks;
+  el: HTMLElement;
+
+  scaleTracker?: ScaleTracker
+
+  distance = 0
+
+  constructor(el: HTMLElement, callbacks: Callbacks) {
+    this.callbacks = callbacks;
+    this.el = el;
+  }
+
+  private removeTouch(identifier: number) {
+    let index = this.touches.findIndex(t => t.identifier == identifier)
+    if (index >= 0) {
+      this.touches.splice(index, 1)
+    }
+  }
+
+  private getTouch(identifier: number): MyTouch | null {
+    let index = this.touches.findIndex(t => t.identifier == identifier)
+    if (index >= 0) return this.touches[index]
+    else return null
+  }
+
+  private getX(touch: Touch) {
+    //return touch.pageX - this.el.offsetLeft
+    return touch.pageX 
+  }
+  private getY(touch: Touch) {
+    //return touch.pageY - this.el.offsetTop
+    return touch.pageY 
+  }
+
+
+  start(list: TouchList) {
+    for (var i = 0; i < list.length; i++) {
+      let touch = list[i];
+      this.removeTouch(touch.identifier);
+      this.touches.push({
+        lastX: this.getX(touch),
+        lastY: this.getY(touch),
+        dx: 0,
+        dy: 0,
+        identifier: touch.identifier
+      })
+    }
+
+    if (this.touches.length == 1) {
+      this.distance = 0;
+    }
+
+    this.updateFocus();
+
+  }
+
+  updateFocus() {
+    if (this.touches.length < 2) {
+      this.scaleTracker = undefined;
+      return
+    }
+
+    let t1 = this.touches[0];
+    let t2 = this.touches[1];
+
+    let focusX = (t1.lastX + t2.lastX) / 2;
+    let focusY = (t1.lastY + t2.lastY) / 2;
+    let distance = Math.sqrt(Math.pow(t2.lastX - t1.lastX, 2) + Math.pow(t2.lastY - t1.lastY, 2));
+    this.scaleTracker = { focusX, focusY, distance }
+  }
+
+
+  end(list: TouchList) {
+    console.log('end')
+    for (var i = 0; i < list.length; i++) {
+      this.removeTouch(list[i].identifier);
+    }
+    this.updateFocus();
+
+    if (this.touches.length <= 0 && this.distance == 0) {
+      let touch = list[0];
+      let el = touch.target as HTMLElement
+      //console.log('kkkkkk', el.offsetLeft, el.offsetTop)
+      //this.callbacks?.tap?.(touch.clientX - el.offsetLeft, touch.clientY - el.offsetTop)
+      this.callbacks?.tap?.(touch.clientX , touch.clientY)
+    }
+  }
+
+
+  move(list: TouchList) {
+    for (var i = 0; i < list.length; i++) {
+      let touch = list[i];
+      let mytouch = this.getTouch(touch.identifier);
+      if (mytouch == null) continue;
+      mytouch.dx = this.getX(touch) - mytouch.lastX
+      mytouch.dy = this.getY(touch) - mytouch.lastY
+      mytouch.lastX = this.getX(touch)
+      mytouch.lastY = this.getY(touch)
+
+      if (this.scaleTracker) {
+
+        let t1 = this.touches[0];
+        let t2 = this.touches[1];
+
+        let focusX = (t1.lastX + t2.lastX) / 2;
+        let focusY = (t1.lastY + t2.lastY) / 2;
+        let distance = Math.sqrt(Math.pow(t2.lastX - t1.lastX, 2) + Math.pow(t2.lastY - t1.lastY, 2));
+        let dx = focusX - this.scaleTracker.focusX;
+        let dy = focusY - this.scaleTracker.focusY;
+
+        let scale = distance / this.scaleTracker.distance;
+
+        console.log(`dx=${dx}, dy=${dy}, scale=${scale}, distance=${distance}`);
+
+        this.callbacks.drag?.(dx, dy);
+        this.callbacks.zoom?.(scale, focusX, focusY);
+
+        this.scaleTracker.focusX = focusX;
+        this.scaleTracker.focusY = focusY;
+        this.scaleTracker.distance = distance;
+
+        this.distance += Math.abs(dx) + Math.abs(dy)
+
+
+      } else {
+        //if(mytouch.dx > 0 || mytouch.dy >0) {
+        this.callbacks.drag?.(mytouch.dx, mytouch.dy)
+        // }
+        this.distance += Math.abs(mytouch.dx) + Math.abs(mytouch.dy)
+      }
+    }
+  }
+
+
+
+
+
+}
+
+
+export class Gesture {
+
+
+  constructor(el: HTMLElement, callbacks: Callbacks) {
+
+
+
+    var dx, dy;
+    var isDown = false;
+    var lastX: number = 0;
+    var lastY: number = 0;
+    var distance = 0;
+
+    el.addEventListener('mousedown', e => {
+      isDown = true;
+      lastX = e.clientX;
+      lastY = e.clientY;
+      distance = 0;
+    })
+
+    document.addEventListener('mouseup', e => {
+      isDown = false;
+      if (distance == 0 && e.target == el) {
+        callbacks.tap?.(e.offsetX, e.offsetY)
+      }
+    })
+
+
+    document.addEventListener('mousemove', e => {
+      if (isDown) {
+        e.preventDefault()
+        dx = e.clientX - lastX;
+        dy = e.clientY - lastY;
+        lastX = e.clientX;
+        lastY = e.clientY;
+        distance += Math.abs(dx) + Math.abs(dy)
+        callbacks?.drag?.(dx, dy);
+      }
+    })
+
+    el.addEventListener('wheel', e => {
+      e.preventDefault()
+      let scale = e.deltaY * -0.01 + 1;
+      callbacks.zoom?.(scale, e.offsetX, e.offsetY)
+    })
+
+    el.addEventListener('click', e => {
+      e.preventDefault()
+      //callbacks.tap?.(e.offsetX, e.offsetY)
+    })
+
+
+
+
+    const tracker = new TouchTracker(el, callbacks);
+
+
+
+    el.addEventListener('touchstart', e => {
+      e.preventDefault();
+      tracker.start(e.changedTouches);
+    })
+
+    el.addEventListener('touchmove', e => {
+      tracker.move(e.changedTouches)
+    })
+
+    el.addEventListener('touchend', e => {
+      tracker.end(e.changedTouches)
+    })
+    el.addEventListener('touchcancel', e => {
+      tracker.end(e.changedTouches)
+    })
+
+
+  }
+}
+

+ 40 - 0
src/base/ImageLayer.ts

@@ -0,0 +1,40 @@
+import { Scene } from "./Scene"
+import { TextureLayer } from "./TextureLayer";
+
+
+
+export class ImageLayer extends TextureLayer {
+
+
+  constructor(
+    scene: Scene,
+    image: HTMLImageElement | HTMLCanvasElement,
+    width : number, 
+    height : number,
+    filterType: GLint = scene.gl.LINEAR,
+    fragmentShader: string | null = null
+  ) {
+    const gl = scene.gl;
+
+    // Create a texture.
+    let texture = gl.createTexture()!;
+    gl.bindTexture(gl.TEXTURE_2D, texture);
+
+    // Set the parameters so we can render any size image.
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterType);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterType);
+
+    //Upload the image into the texture.
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+    super(scene, texture, image.width, image.height, width, height, fragmentShader)
+
+  }
+
+}
+
+
+
+

+ 487 - 0
src/base/ImageShaders.ts

@@ -0,0 +1,487 @@
+
+
+
+
+export namespace ImageShaders {
+
+
+
+  export const LineArtShader = /*glsl*/ `
+
+  precision highp float;
+  uniform sampler2D u_image;
+  uniform float u_scale;
+  uniform vec2 u_texSize;
+  varying vec2 v_texCoord;
+  
+  const float w1 = 0.147761;
+  const float w2 = 0.118318; 
+  const float w3 = 0.0947416; 
+  
+  
+  vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+    vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+    vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+    vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+  
+    vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+    vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+    vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+  
+    vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+    vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+    vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+  
+    return 
+      C00 + C01 + C02 +
+      C10 + C11 + C12 +
+      C20 + C21 + C22 ;
+  }
+  
+  void main() {
+  
+    vec4 color = GaussianBlur(u_image, v_texCoord, u_texSize);
+    //gl_FragColor = color;
+    if(color.w >=0.4) {
+      gl_FragColor = vec4(0.15, 0.15, 0.15, 1);
+    }else{
+        gl_FragColor = vec4(0, 0, 0, 0);
+    }
+  }
+  
+  `;
+
+
+
+  export const BaseShader = /*glsl*/ `
+        precision mediump float;
+        uniform sampler2D u_image;
+        uniform float u_scale;
+        varying vec2 v_texCoord;
+        void main() {
+             vec4 color = texture2D(u_image, v_texCoord);
+             if(u_scale > 1.0 ) {
+                if(color.w >=0.4) {
+                    gl_FragColor = vec4(0.15, 0.15, 0.15, 1);
+                }else{
+                    gl_FragColor = vec4(0, 0, 0, 0);
+                }
+             }else{
+                gl_FragColor = vec4(0.15, 0.15, 0.15, color.a);
+             }
+        }
+  `;
+
+  export const Avg9 = /*glsl*/ `
+
+    precision highp float;
+    uniform sampler2D u_image;
+    uniform float u_scale;
+    uniform vec2 u_texSize;
+    varying vec2 v_texCoord;
+    
+    float sample(in sampler2D image, in vec2 texCoord, in vec2 texSize) {
+        const int n = 1;
+        float total = 0.0;
+        float alpha = 0.0;
+        float base = 0.0;
+        
+        for(int i = -n; i<=n ; i++) {
+            float ii = float(i);
+            for(int j = -n; j<=n ; j++) {
+                float jj = float(j);
+                vec2 coord = texCoord + vec2(ii * texSize.x, jj * texSize.y);
+                vec4 cc = texture2D(image, coord);
+                if(i==0  && j==0) base = cc.a;
+                total += 1.0;
+                alpha += cc.a;
+            }
+        }
+        alpha = alpha /  total;
+        return max(alpha, base);
+    }
+    
+    
+    void main() {
+        if(u_scale > 1.0 ) {
+            float alpha = sample(u_image, v_texCoord, u_texSize);
+            if(alpha >=0.4) {
+                gl_FragColor = vec4(0.15, 0.15, 0.15, 1);
+            }else{
+                gl_FragColor = vec4(0, 0, 0, 0);
+            }
+        }else{
+            vec4 color = texture2D(u_image, v_texCoord);
+            gl_FragColor = vec4(0.15, 0.15, 0.15, color.a);
+        }
+        
+    }
+
+  `;
+
+
+  export const BilinearManual = /*glsl*/ `
+
+        precision highp float;
+        uniform sampler2D u_image;
+        uniform float u_scale;
+        uniform vec2 u_texSize;
+        varying vec2 v_texCoord;
+        
+        
+        void main() {
+            vec2 textureSize = 1. / u_texSize;
+            vec2 f = fract(v_texCoord * textureSize + .5);
+            vec2 uv = (floor(v_texCoord * textureSize - .5) + .5 ) / textureSize ;
+            
+            vec4 tl = texture2D(u_image, uv);
+            vec4 tr = texture2D(u_image, uv + vec2(u_texSize.x, 0.0));
+            vec4 bl = texture2D(u_image, uv + vec2(0.0, u_texSize.y));
+            vec4 br = texture2D(u_image, uv + vec2(u_texSize.x, u_texSize.y));
+            vec4 tA = mix( tl, tr, f.x );
+            vec4 tB = mix( bl, br, f.x );
+            vec4 result = mix( tA, tB, f.y );
+            gl_FragColor = result;
+        }
+
+  `;
+
+
+  export const BicubicLagrange = /*glsl*/ `
+  
+  precision highp float;
+  uniform sampler2D u_image;
+  uniform float u_scale;
+  uniform vec2 u_texSize;
+  varying vec2 v_texCoord;
+  
+  
+  
+  float c_x0 = -1.0;
+  float c_x1 =  0.0;
+  float c_x2 =  1.0;
+  float c_x3 =  2.0;
+  
+  
+  
+  
+  vec4 CubicLagrange(vec4 A, vec4 B, vec4 C, vec4 D, float t) {
+    return A *
+      ((t - c_x1) / (c_x0 - c_x1) *
+      (t - c_x2) / (c_x0 - c_x2) *
+      (t - c_x3) / (c_x0 - c_x3)) +
+      B *
+      ((t - c_x0) / (c_x1 - c_x0) *
+      (t - c_x2) / (c_x1 - c_x2) *
+      (t - c_x3) / (c_x1 - c_x3)) +
+      C *
+      ((t - c_x0) / (c_x2 - c_x0) *
+      (t - c_x1) / (c_x2 - c_x1) *
+      (t - c_x3) / (c_x2 - c_x3)) +
+      D *
+      ((t - c_x0) / (c_x3 - c_x0) *
+      (t - c_x1) / (c_x3 - c_x1) *
+      (t - c_x2) / (c_x3 - c_x2));
+  }
+  
+  vec4 BicubicLagrange(sampler2D image, vec2 texCoord, vec2 pixelSize) {
+  
+    vec2 textureSize = 1. / pixelSize;
+    vec2 f = fract(texCoord * textureSize + .5);
+    vec2 uv = (floor(texCoord * textureSize - .5) + .5) / textureSize;
+  
+    vec4 C00 = texture2D(image, uv + vec2(-pixelSize.x, -pixelSize.y));
+    vec4 C10 = texture2D(image, uv + vec2(0.0, -pixelSize.y));
+    vec4 C20 = texture2D(image, uv + vec2(pixelSize.x, -pixelSize.y));
+    vec4 C30 = texture2D(image, uv + vec2(2.0 * pixelSize.x, -pixelSize.y));
+  
+    vec4 C01 = texture2D(image, uv + vec2(-pixelSize.x, 0.0));
+    vec4 C11 = texture2D(image, uv + vec2(0.0, 0.0));
+    vec4 C21 = texture2D(image, uv + vec2(pixelSize.x, 0.0));
+    vec4 C31 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 0.0));
+  
+    vec4 C02 = texture2D(image, uv + vec2(-pixelSize.x, pixelSize.y));
+    vec4 C12 = texture2D(image, uv + vec2(0.0, pixelSize.y));
+    vec4 C22 = texture2D(image, uv + vec2(pixelSize.x, pixelSize.y));
+    vec4 C32 = texture2D(image, uv + vec2(2.0 * pixelSize.x, pixelSize.y));
+  
+    vec4 C03 = texture2D(image, uv + vec2(-pixelSize.x, 2.0 * pixelSize.y));
+    vec4 C13 = texture2D(image, uv + vec2(0.0, 2.0 * pixelSize.y));
+    vec4 C23 = texture2D(image, uv + vec2(pixelSize.x, 2.0 * pixelSize.y));
+    vec4 C33 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 2.0 * pixelSize.y));
+  
+    vec4 CP0X = CubicLagrange(C00, C10, C20, C30, f.x);
+    vec4 CP1X = CubicLagrange(C01, C11, C21, C31, f.x);
+    vec4 CP2X = CubicLagrange(C02, C12, C22, C32, f.x);
+    vec4 CP3X = CubicLagrange(C03, C13, C23, C33, f.x);
+  
+    return CubicLagrange(CP0X, CP1X, CP2X, CP3X, f.y);
+  
+  }
+  
+  void main() {
+    gl_FragColor = BicubicLagrange(u_image, v_texCoord, u_texSize);
+  }
+  
+
+
+  `;
+
+
+
+
+
+  export const CommonAvg9 = /*glsl*/ `
+
+  precision highp float;
+  uniform sampler2D u_image;
+  uniform float u_scale;
+  uniform vec2 u_texSize;
+  varying vec2 v_texCoord;
+  
+  vec4 AvgN(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+    const int n = 1;
+    float total = 0.0;
+    vec4 avg = vec4(0,0,0,0);
+    float base = 0.0;
+  
+    for(int i = -n; i <= n; i++) {
+      float ii = float(i);
+      for(int j = -n; j <= n; j++) {
+        float jj = float(j);
+        vec2 coord = texCoord + vec2(ii * pixelSize.x, jj * pixelSize.y);
+        vec4 cc = texture2D(image, coord);
+        total += 1.0;
+        avg += cc;
+      }
+    }
+    avg = avg / total;
+    return avg;
+  }
+  
+  void main() {
+    gl_FragColor = AvgN(u_image, v_texCoord, u_texSize);
+  }
+
+`;
+
+  export const CommonAvg5 = /*glsl*/ `
+
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+vec4 Avg5(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C0 = texture2D(image, texCoord );
+  vec4 C1 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y));
+  vec4 C2 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y));
+  vec4 C3 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y));
+  vec4 C4 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y));
+  return (C0 + C1 + C2 + C3 + C4) / 5.0;
+}
+
+void main() {
+  gl_FragColor = Avg5(u_image, v_texCoord, u_texSize);
+}
+
+`;
+
+
+
+
+
+
+  export const CommonBlur = /*glsl*/ `
+
+  precision highp float;
+  uniform sampler2D u_image;
+  uniform float u_scale;
+  uniform vec2 u_texSize;
+  varying vec2 v_texCoord;
+  
+  vec4 Blur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+    const int n = 1;
+    float total = 0.0;
+    vec4 avg = vec4(0,0,0,0);
+    float base = 0.0;
+  
+    for(int i = -n; i <= n; i++) {
+      float ii = float(i);
+      for(int j = -n; j <= n; j++) {
+        float jj = float(j);
+        vec2 coord = texCoord + vec2(ii * pixelSize.x, jj * pixelSize.y);
+        vec4 cc = texture2D(image, coord);
+        total += 1.0;
+        avg += cc;
+      }
+    }
+    avg = avg / total;
+    return avg;
+  }
+  
+  void main() {
+    vec4 color = Blur(u_image, v_texCoord, u_texSize);
+    gl_FragColor = color;
+  }
+
+`;
+
+
+
+
+  export const CommonGaussianBlur = /*glsl*/ `
+
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+const float w1 = 0.147761;
+const float w2 = 0.118318; 
+const float w3 = 0.0947416; 
+
+
+vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+  vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+  vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+
+  vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+  vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+  vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+
+  vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+  vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+  vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+
+  return 
+    C00 + C01 + C02 +
+    C10 + C11 + C12 +
+    C20 + C21 + C22 ;
+}
+
+void main() {
+
+  vec4 color = GaussianBlur(u_image, v_texCoord, u_texSize);
+  gl_FragColor = color;
+}
+
+`;
+
+
+export const CommonGaussianBlurCut = /*glsl*/ `
+
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+const float w1 = 0.147761;
+const float w2 = 0.118318; 
+const float w3 = 0.0947416; 
+
+
+vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+  vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+  vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+
+  vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+  vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+  vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+
+  vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+  vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+  vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+
+  return 
+    C00 + C01 + C02 +
+    C10 + C11 + C12 +
+    C20 + C21 + C22 ;
+}
+
+void main() {
+
+  vec4 color = GaussianBlur(u_image, v_texCoord, u_texSize);
+  gl_FragColor = color;
+
+  if(color.a >= 0.4) {
+    gl_FragColor = color;
+  }else{
+    gl_FragColor = vec4(0,0,0,0);
+  }
+  
+}
+
+`;
+
+
+
+  export const BicubicHermite = /*glsl*/ `
+
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+
+vec4 CubicHermite(vec4 A, vec4 B, vec4 C, vec4 D, float t) {
+  float t2 = t * t;
+  float t3 = t * t * t;
+  vec4 a = -A / 2.0 + (3.0 * B) / 2.0 - (3.0 * C) / 2.0 + D / 2.0;
+  vec4 b = A - (5.0 * B) / 2.0 + 2.0 * C - D / 2.0;
+  vec4 c = -A / 2.0 + C / 2.0;
+  vec4 d = B;
+
+  return a * t3 + b * t2 + c * t + d;
+}
+
+vec4 BicubicHermite(sampler2D image, vec2 texCoord, vec2 pixelSize) {
+
+  vec2 textureSize = 1. / pixelSize;
+  vec2 f = fract(texCoord * textureSize + .5);
+  vec2 uv = (floor(texCoord * textureSize - .5) + .5) / textureSize;
+
+  vec4 C00 = texture2D(image, uv + vec2(-pixelSize.x, -pixelSize.y));
+  vec4 C10 = texture2D(image, uv + vec2(0.0, -pixelSize.y));
+  vec4 C20 = texture2D(image, uv + vec2(pixelSize.x, -pixelSize.y));
+  vec4 C30 = texture2D(image, uv + vec2(2.0 * pixelSize.x, -pixelSize.y));
+
+  vec4 C01 = texture2D(image, uv + vec2(-pixelSize.x, 0.0));
+  vec4 C11 = texture2D(image, uv + vec2(0.0, 0.0));
+  vec4 C21 = texture2D(image, uv + vec2(pixelSize.x, 0.0));
+  vec4 C31 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 0.0));
+
+  vec4 C02 = texture2D(image, uv + vec2(-pixelSize.x, pixelSize.y));
+  vec4 C12 = texture2D(image, uv + vec2(0.0, pixelSize.y));
+  vec4 C22 = texture2D(image, uv + vec2(pixelSize.x, pixelSize.y));
+  vec4 C32 = texture2D(image, uv + vec2(2.0 * pixelSize.x, pixelSize.y));
+
+  vec4 C03 = texture2D(image, uv + vec2(-pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C13 = texture2D(image, uv + vec2(0.0, 2.0 * pixelSize.y));
+  vec4 C23 = texture2D(image, uv + vec2(pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C33 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 2.0 * pixelSize.y));
+
+  vec4 CP0X = CubicHermite(C00, C10, C20, C30, f.x);
+  vec4 CP1X = CubicHermite(C01, C11, C21, C31, f.x);
+  vec4 CP2X = CubicHermite(C02, C12, C22, C32, f.x);
+  vec4 CP3X = CubicHermite(C03, C13, C23, C33, f.x);
+
+  return CubicHermite(CP0X, CP1X, CP2X, CP3X, f.y);
+
+}
+
+void main() {
+  gl_FragColor = BicubicHermite(u_image, v_texCoord, u_texSize);
+}
+
+
+`;
+
+
+}

+ 324 - 0
src/base/Scene.ts

@@ -0,0 +1,324 @@
+import { Rect } from "./2d";
+import { Animator, Interpolator } from "./Animator";
+import { Gesture } from "./Gesture";
+import { m4 } from "./m4";
+
+export interface Disposable {
+  dispose(): void;
+}
+
+export interface Layer {
+  preDraw(): void;
+  draw(): void;
+  tap(cx: number, cy: number, sx: number, sy: number): void;
+  scale(scale: number): void;
+  dispose(): void;
+}
+
+export class LayerAB implements Layer {
+  preDraw(): void {}
+  draw(): void {}
+  tap(cx: number, cy: number, sx: number, sy: number): void {}
+  scale(scale: number): void {}
+  dispose(): void {}
+}
+
+export class Padding {
+  constructor(
+    readonly left: number,
+    readonly top: number,
+    readonly right: number,
+    readonly bottom: number,
+  ) {}
+
+  equals(other: Padding) {
+    return (
+      this.left == other.left &&
+      this.top == other.top &&
+      this.right == other.right &&
+      this.bottom == other.bottom
+    );
+  }
+}
+
+export class Scene implements Disposable {
+  layers: Array<Layer> = [];
+  testLayers: Array<Layer> = [];
+
+  animators: Array<Animator> = [];
+
+  userMat: m4.Matrix4 = m4.identity();
+  bestFitMat: m4.Matrix4 = m4.identity();
+  projectionMat: m4.Matrix4 = m4.identity();
+  resultMat: m4.Matrix4 = m4.identity();
+
+  width: number = 0;
+  height: number = 0;
+  contentWidth: number = 0;
+  contentHeight: number = 0;
+
+  padding = new Padding(0, 0, 0, 0);
+
+  pendingDraw = false;
+
+  constructor(
+    readonly gl: WebGL2RenderingContext,
+    public readonly ratio: number,
+    public animationFrameProvider: AnimationFrameProvider = window,
+    public interact: boolean = true,
+  ) {
+    this.updateViewport();
+
+    let self = this;
+
+    if (window && interact) {
+      new Gesture(gl.canvas as HTMLElement, {
+        // drag: self.drag.bind(self),
+        // zoom: self.scaleAt.bind(self),
+        tap: self.tap.bind(self),
+      });
+    }
+
+    m4.identity();
+  }
+
+  updateViewport() {
+    console.log("viewport update.");
+    const gl = this.gl;
+    const canvas = gl.canvas as HTMLCanvasElement;
+    //canvas.width = canvas.clientWidth * this.ratio
+    //canvas.height = canvas.clientHeight * this.ratio
+    this.invalidate();
+
+    if (canvas.width != this.width || canvas.height != this.height) {
+      this.width = canvas.width;
+      this.height = canvas.height;
+      m4.projection(this.width, this.height, this.projectionMat);
+      this.updateBestFit();
+      this.updateResultMat();
+    }
+  }
+
+  private isBestMatSet: boolean = false;
+
+  private updateBestFit() {
+    if (this.contentWidth == 0 || this.width == 0) return;
+
+    const box = new Rect(
+      this.padding.left,
+      this.padding.top,
+      this.width - this.padding.right - this.padding.left,
+      this.height - this.padding.top - this.padding.bottom,
+    );
+    const scale = Math.min(
+      box.width / this.contentWidth,
+      box.height / this.contentHeight,
+    );
+    const tx = box.center.x - (this.contentWidth * scale) / 2;
+    const ty = box.center.y - (this.contentHeight * scale) / 2;
+    m4.identity(this.bestFitMat);
+    this.bestFitMat[0] = scale;
+    this.bestFitMat[5] = scale;
+    this.bestFitMat[12] = tx;
+    this.bestFitMat[13] = ty;
+
+    if (!this.isBestMatSet) {
+      m4.copy(this.bestFitMat, this.userMat);
+      this.invalidate();
+      this.isBestMatSet = true;
+    }
+  }
+
+  updateResultMat() {
+    if (this.contentWidth == 0 || this.width == 0) return;
+
+    const box = new Rect(
+      this.padding.left,
+      this.padding.top,
+      this.width - this.padding.right - this.padding.left,
+      this.height - this.padding.top - this.padding.bottom,
+    );
+    // const scale = Math.min(box.width / this.contentWidth, box.height / this.contentHeight)
+    // const tx = box.center.x - this.contentWidth * scale / 2
+    // const ty = box.center.y - this.contentHeight * scale / 2
+    // m4.identity(this.resultMat)
+    // this.resultMat[0] = scale
+    // this.resultMat[5] = scale
+    // this.resultMat[12] = tx
+    // this.resultMat[13] = ty
+
+    let rate = this.width > this.height ? 0.6 : 0.7;
+    let scaleX = (this.width * rate) / this.contentWidth;
+    let scaleY = (this.height * rate) / this.contentHeight;
+    let scale = Math.min(scaleX, scaleY);
+    const tx = box.center.x - (this.contentWidth * scale) / 2;
+    const ty = box.center.y - (this.contentHeight * scale) / 2;
+    m4.identity(this.resultMat);
+    this.resultMat[0] = scale;
+    this.resultMat[5] = scale;
+    this.resultMat[12] = tx;
+    this.resultMat[13] = ty;
+  }
+
+  setContentPadding(padding: Padding) {
+    if (!this.padding.equals(padding)) {
+      this.padding = padding;
+      this.updateBestFit();
+      this.updateResultMat();
+    }
+  }
+
+  setContentSize(width: number, height: number) {
+    if (width != this.contentWidth || height != this.contentWidth) {
+      this.contentWidth = width;
+      this.contentHeight = height;
+      this.updateBestFit();
+      this.updateResultMat();
+    }
+  }
+
+  addLayer(layer: Layer) {
+    this.layers.push(layer);
+    this.invalidate();
+  }
+
+  invalidate() {
+    if (this.pendingDraw) return;
+    this.pendingDraw = true;
+
+    //requestAnimationFrame(() => {
+    this.animationFrameProvider.requestAnimationFrame(() => {
+      this.draw();
+    });
+  }
+
+  draw() {
+    this.animators.forEach((a) => a.update());
+
+    this.layers.forEach((l) => l.preDraw());
+
+    const gl = this.gl;
+
+    gl.viewport(0, 0, this.width, this.height);
+    let a = 0xef / 255;
+    gl.clearColor(a, a, a, 1);
+    gl.clear(gl.COLOR_BUFFER_BIT);
+    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+    gl.enable(gl.BLEND);
+    gl.disable(gl.DEPTH_TEST);
+
+    this.layers.forEach((l) => l.draw());
+    this.pendingDraw = false;
+
+    this.animators = this.animators.filter((a) => !a.removable());
+
+    if (this.animators.length > 0) {
+      this.invalidate();
+    }
+  }
+
+  get drawMatrix(): m4.Matrix4 {
+    return m4.multiply(this.projectionMat, this.userMat);
+  }
+
+  drag(dx: number, dy: number) {
+    let t = m4.translation(dx * this.ratio, dy * this.ratio, 0);
+    this.userMat = m4.multiply(t, this.userMat);
+    this.invalidate();
+  }
+
+  scaleAt(scale: number, focusX: number, focusY: number) {
+    focusX *= this.ratio;
+    focusY *= this.ratio;
+    let t = m4.scaleAt(scale, scale, focusX, focusY);
+    this.userMat = m4.multiply(t, this.userMat);
+    this.layers.forEach((l) => l.scale(this.userMat[0]));
+    this.invalidate();
+  }
+
+  tap(x: number, y: number) {
+    let sx = x * this.ratio;
+    let sy = y * this.ratio;
+    let [cx, cy] = m4.transformPoint(
+      m4.inverse(this.userMat),
+      new Float32Array([sx, sy, 0]),
+    );
+    this.layers.forEach((l) => l.tap(cx, cy, sx, sy));
+  }
+
+  addAnimator(animator: Animator) {
+    this.animators.push(animator);
+    this.invalidate();
+  }
+
+  addTestLayer(layer: Layer) {
+    this.testLayers.push(layer);
+    if (this.testLayers.length <= 1) this.layers.push(layer);
+    this.invalidate();
+  }
+
+  toggleTestLayer() {
+    if (this.testLayers.length == 0) return;
+
+    let testLayerIndex = this.testLayers.findIndex((l) => {
+      return this.layers.indexOf(l) >= 0;
+    });
+
+    let next = (testLayerIndex + 1) % this.testLayers.length;
+    let nextLayer = this.testLayers[next];
+
+    if (testLayerIndex >= 0) {
+      this.layers = this.layers.filter(
+        (l) => l != this.testLayers[testLayerIndex],
+      );
+    }
+
+    console.log(`toggleTestLayer, layer=${nextLayer}`);
+
+    this.layers.push(nextLayer);
+    this.invalidate();
+  }
+
+  setScale(scale: number) {
+    m4.scaling(scale, scale, 1, this.userMat);
+    this.invalidate();
+  }
+
+  updateUserMat(mat: m4.Matrix4) {
+    m4.copy(mat, this.userMat);
+    this.invalidate();
+  }
+
+  matrixAnimationTo(
+    endMat: m4.Matrix4,
+    duration: number,
+    interpolator?: Interpolator,
+    onEnd?: Function,
+  ) {
+    const startMat = m4.copy(this.userMat);
+    const mat = new Float32Array(16);
+    const animator = new Animator(
+      duration,
+      () => {
+        m4.lerp(startMat, endMat, mat, animator.value());
+        this.updateUserMat(mat);
+      },
+      () => {
+        onEnd?.();
+      },
+      interpolator,
+    );
+    this.addAnimator(animator);
+  }
+
+  resetToBestFit() {
+    this.matrixAnimationTo(this.bestFitMat, 600);
+  }
+
+  dispose() {
+    while (this.layers.length > 0) {
+      let layer = this.layers.pop();
+      layer?.dispose();
+    }
+  }
+}

+ 145 - 0
src/base/TextureLayer.ts

@@ -0,0 +1,145 @@
+import { fillRectangle} from "./2d"
+import { LayerAB, Scene } from "./Scene"
+import { createProgram, createShader } from "./utils"
+
+
+
+
+export class TextureLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_image;
+    varying vec2 v_texCoord;
+
+    void main() {
+      vec4 color = texture2D(u_image, v_texCoord);
+      gl_FragColor = color;
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uScaleLoc: WebGLUniformLocation
+  uTexSizeLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+  texcoordBuffer: WebGLBuffer
+
+  vertexArray = new Float32Array(12)
+  texCoordArray = new Float32Array(12)
+
+  dispose() {
+    let gl = this.scene.gl
+    gl.deleteProgram(this.program)
+    gl.deleteTexture(this.texture)
+    gl.deleteBuffer(this.texcoordBuffer)
+    gl.deleteBuffer(this.vertexBuffer)
+  }
+
+  constructor(
+    public readonly scene: Scene,
+    private texture : WebGLTexture, 
+    public readonly texWidth: number, 
+    public readonly texHeight: number, 
+    public readonly width : number, 
+    public readonly height : number, 
+    private fragmentShader: string | null = null
+  ) {
+    super()
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, fragmentShader || this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+    this.uScaleLoc = gl.getUniformLocation(this.program, "u_scale")!
+    this.uTexSizeLoc = gl.getUniformLocation(this.program, "u_texSize")!
+
+
+    fillRectangle(this.vertexArray, 0, 0, 0, width, height )
+    fillRectangle(this.texCoordArray, 0, 0, 0, 1, 1)
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+
+    this.texcoordBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW);
+
+  }
+
+
+  get currentScale() : number {
+    return this.scene.userMat[0] * (this.width / this.texWidth)
+  }
+
+
+  override draw() {
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+
+    gl.uniform1f(this.uScaleLoc, this.currentScale)
+    gl.uniform2f(this.uTexSizeLoc, 1. / this.texWidth, 1. / this.texHeight)
+
+
+    gl.activeTexture(gl.TEXTURE0)
+    gl.bindTexture(gl.TEXTURE_2D, this.texture)
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+  }
+
+
+  override scale(scale: number): void {
+    //console.log(`scale=${scale}`)
+  }
+
+  toString(): string {
+    return `TextureLayer()`
+  }
+
+}
+
+
+
+

+ 95 - 0
src/base/Triangle.ts

@@ -0,0 +1,95 @@
+import { Animator } from "./Animator";
+import { Layer, LayerAB, Scene } from "./Scene";
+import { createProgram, createShader } from "./utils";
+
+
+
+
+export class Triangle extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    uniform mat4 u_matrix; 
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+    }
+  `
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform float u_alpha; 
+
+    void main() {
+      gl_FragColor = vec4(1, 0, 0, u_alpha);
+    }
+  
+  `
+
+  program: WebGLProgram;
+
+  positionLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uAlphaLoc: WebGLUniformLocation
+
+
+  positionBuffer: WebGLBuffer
+
+  constructor(private scene: Scene) {
+    super()
+
+    const gl = scene.gl
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+
+    this.positionLoc = gl.getAttribLocation(this.program, 'a_position');
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!!;
+    this.uAlphaLoc = gl.getUniformLocation(this.program, 'u_alpha')!!;
+
+    this.positionBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
+      0, 0,
+      0, 100,
+      100, 0,
+      100, 0,
+      0, 100,
+      100, 100
+    ]), gl.STATIC_DRAW);
+  }
+
+
+
+  private colorAnimator: Animator | null = null
+
+  private alpha: number = 1
+
+
+  override tap(cx: number, cy: number, sx: number, sy: number): void {
+    this.colorAnimator?.cancel()
+
+    this.colorAnimator = new Animator(1000, (ani) => {
+      this.alpha = 1 - ani.progress
+    }, () => {
+      this.alpha = 1
+    })
+    this.scene.addAnimator(this.colorAnimator)
+  }
+
+
+  draw() {
+    const gl = this.scene.gl
+    gl.useProgram(this.program)
+    gl.enableVertexAttribArray(this.positionLoc)
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
+    gl.vertexAttribPointer(this.positionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+    gl.uniform1f(this.uAlphaLoc, this.alpha)
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+  }
+
+}

+ 56 - 0
src/base/glsl/BicubicHermite.glsl

@@ -0,0 +1,56 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+
+vec4 CubicHermite(vec4 A, vec4 B, vec4 C, vec4 D, float t) {
+  float t2 = t * t;
+  float t3 = t * t * t;
+  vec4 a = -A / 2.0 + (3.0 * B) / 2.0 - (3.0 * C) / 2.0 + D / 2.0;
+  vec4 b = A - (5.0 * B) / 2.0 + 2.0 * C - D / 2.0;
+  vec4 c = -A / 2.0 + C / 2.0;
+  vec4 d = B;
+
+  return a * t3 + b * t2 + c * t + d;
+}
+
+vec4 BicubicHermite(sampler2D image, vec2 texCoord, vec2 pixelSize) {
+
+  vec2 textureSize = 1. / pixelSize;
+  vec2 f = fract(texCoord * textureSize + .5);
+  vec2 uv = (floor(texCoord * textureSize - .5) + .5) / textureSize;
+
+  vec4 C00 = texture2D(image, uv + vec2(-pixelSize.x, -pixelSize.y));
+  vec4 C10 = texture2D(image, uv + vec2(0.0, -pixelSize.y));
+  vec4 C20 = texture2D(image, uv + vec2(pixelSize.x, -pixelSize.y));
+  vec4 C30 = texture2D(image, uv + vec2(2.0 * pixelSize.x, -pixelSize.y));
+
+  vec4 C01 = texture2D(image, uv + vec2(-pixelSize.x, 0.0));
+  vec4 C11 = texture2D(image, uv + vec2(0.0, 0.0));
+  vec4 C21 = texture2D(image, uv + vec2(pixelSize.x, 0.0));
+  vec4 C31 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 0.0));
+
+  vec4 C02 = texture2D(image, uv + vec2(-pixelSize.x, pixelSize.y));
+  vec4 C12 = texture2D(image, uv + vec2(0.0, pixelSize.y));
+  vec4 C22 = texture2D(image, uv + vec2(pixelSize.x, pixelSize.y));
+  vec4 C32 = texture2D(image, uv + vec2(2.0 * pixelSize.x, pixelSize.y));
+
+  vec4 C03 = texture2D(image, uv + vec2(-pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C13 = texture2D(image, uv + vec2(0.0, 2.0 * pixelSize.y));
+  vec4 C23 = texture2D(image, uv + vec2(pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C33 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 2.0 * pixelSize.y));
+
+  vec4 CP0X = CubicHermite(C00, C10, C20, C30, f.x);
+  vec4 CP1X = CubicHermite(C01, C11, C21, C31, f.x);
+  vec4 CP2X = CubicHermite(C02, C12, C22, C32, f.x);
+  vec4 CP3X = CubicHermite(C03, C13, C23, C33, f.x);
+
+  return CubicHermite(CP0X, CP1X, CP2X, CP3X, f.y);
+
+}
+
+void main() {
+  gl_FragColor = BicubicHermite(u_image, v_texCoord, u_texSize);
+}

+ 73 - 0
src/base/glsl/BicubicLagrange.glsl

@@ -0,0 +1,73 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+
+
+float c_x0 = -1.0;
+float c_x1 =  0.0;
+float c_x2 =  1.0;
+float c_x3 =  2.0;
+
+
+
+
+vec4 CubicLagrange(vec4 A, vec4 B, vec4 C, vec4 D, float t) {
+  return A *
+    ((t - c_x1) / (c_x0 - c_x1) *
+    (t - c_x2) / (c_x0 - c_x2) *
+    (t - c_x3) / (c_x0 - c_x3)) +
+    B *
+    ((t - c_x0) / (c_x1 - c_x0) *
+    (t - c_x2) / (c_x1 - c_x2) *
+    (t - c_x3) / (c_x1 - c_x3)) +
+    C *
+    ((t - c_x0) / (c_x2 - c_x0) *
+    (t - c_x1) / (c_x2 - c_x1) *
+    (t - c_x3) / (c_x2 - c_x3)) +
+    D *
+    ((t - c_x0) / (c_x3 - c_x0) *
+    (t - c_x1) / (c_x3 - c_x1) *
+    (t - c_x2) / (c_x3 - c_x2));
+}
+
+vec4 BicubicLagrange(sampler2D image, vec2 texCoord, vec2 pixelSize) {
+
+  vec2 textureSize = 1. / pixelSize;
+  vec2 f = fract(texCoord * textureSize + .5);
+  vec2 uv = (floor(texCoord * textureSize - .5) + .5) / textureSize;
+
+  vec4 C00 = texture2D(image, uv + vec2(-pixelSize.x, -pixelSize.y));
+  vec4 C10 = texture2D(image, uv + vec2(0.0, -pixelSize.y));
+  vec4 C20 = texture2D(image, uv + vec2(pixelSize.x, -pixelSize.y));
+  vec4 C30 = texture2D(image, uv + vec2(2.0 * pixelSize.x, -pixelSize.y));
+
+  vec4 C01 = texture2D(image, uv + vec2(-pixelSize.x, 0.0));
+  vec4 C11 = texture2D(image, uv + vec2(0.0, 0.0));
+  vec4 C21 = texture2D(image, uv + vec2(pixelSize.x, 0.0));
+  vec4 C31 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 0.0));
+
+  vec4 C02 = texture2D(image, uv + vec2(-pixelSize.x, pixelSize.y));
+  vec4 C12 = texture2D(image, uv + vec2(0.0, pixelSize.y));
+  vec4 C22 = texture2D(image, uv + vec2(pixelSize.x, pixelSize.y));
+  vec4 C32 = texture2D(image, uv + vec2(2.0 * pixelSize.x, pixelSize.y));
+
+  vec4 C03 = texture2D(image, uv + vec2(-pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C13 = texture2D(image, uv + vec2(0.0, 2.0 * pixelSize.y));
+  vec4 C23 = texture2D(image, uv + vec2(pixelSize.x, 2.0 * pixelSize.y));
+  vec4 C33 = texture2D(image, uv + vec2(2.0 * pixelSize.x, 2.0 * pixelSize.y));
+
+  vec4 CP0X = CubicLagrange(C00, C10, C20, C30, f.x);
+  vec4 CP1X = CubicLagrange(C01, C11, C21, C31, f.x);
+  vec4 CP2X = CubicLagrange(C02, C12, C22, C32, f.x);
+  vec4 CP3X = CubicLagrange(C03, C13, C23, C33, f.x);
+
+  return CubicLagrange(CP0X, CP1X, CP2X, CP3X, f.y);
+
+}
+
+void main() {
+  gl_FragColor = BicubicLagrange(u_image, v_texCoord, u_texSize);
+}

+ 20 - 0
src/base/glsl/Bilinear.glsl

@@ -0,0 +1,20 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+void main() {
+  vec2 textureSize = 1. / u_texSize;
+  vec2 f = fract(v_texCoord * textureSize + .5);
+  vec2 uv = (floor(v_texCoord * textureSize - .5) + .5) / textureSize;
+
+  vec4 tl = texture2D(u_image, uv);
+  vec4 tr = texture2D(u_image, uv + vec2(u_texSize.x, 0.0));
+  vec4 bl = texture2D(u_image, uv + vec2(0.0, u_texSize.y));
+  vec4 br = texture2D(u_image, uv + vec2(u_texSize.x, u_texSize.y));
+  vec4 tA = mix(tl, tr, f.x);
+  vec4 tB = mix(bl, br, f.x);
+  vec4 result = mix(tA, tB, f.y);
+  gl_FragColor = result;
+}

+ 34 - 0
src/base/glsl/GaussianBlur.glsl

@@ -0,0 +1,34 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+const float w1 = 0.147761;
+const float w2 = 0.118318; 
+const float w3 = 0.0947416; 
+
+
+vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+  vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+  vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+
+  vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+  vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+  vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+
+  vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+  vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+  vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+
+  return 
+    C00 + C01 + C02 +
+    C10 + C11 + C12 +
+    C20 + C21 + C22 ;
+}
+
+void main() {
+  vec4 color = GaussianBlur(u_image, v_texCoord, u_texSize);
+  gl_FragColor = color;
+}

+ 18 - 0
src/base/glsl/avg5.glsl

@@ -0,0 +1,18 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+vec4 Avg5(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C0 = texture2D(image, texCoord );
+  vec4 C1 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y));
+  vec4 C2 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y));
+  vec4 C3 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y));
+  vec4 C4 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y));
+  return (C0 + C1 + C2 + C3 + C4) / 5.0;
+}
+
+void main() {
+  gl_FragColor = Avg5(u_image, v_texCoord, u_texSize);
+}

+ 29 - 0
src/base/glsl/avg9.glsl

@@ -0,0 +1,29 @@
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+vec4 AvgN(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  const int n = 1;
+  float total = 0.0;
+  vec4 avg = vec4(0,0,0,0);
+  float base = 0.0;
+
+  for(int i = -n; i <= n; i++) {
+    float ii = float(i);
+    for(int j = -n; j <= n; j++) {
+      float jj = float(j);
+      vec2 coord = texCoord + vec2(ii * pixelSize.x, jj * pixelSize.y);
+      vec4 cc = texture2D(image, coord);
+      total += 1.0;
+      avg += cc;
+    }
+  }
+  avg = avg / total;
+  return avg;
+}
+
+void main() {
+  gl_FragColor = AvgN(u_image, v_texCoord, u_texSize);
+}

+ 55 - 0
src/base/index.html

@@ -0,0 +1,55 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="UTF-8" />
+    <title>webgl learn</title>
+
+    <script type="module" src="/src/base/index.ts"></script>
+    <meta
+      name="viewport"
+      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
+    />
+  </head>
+
+  <style>
+    * {
+      padding: 0;
+      margin: 0;
+    }
+
+    #container {
+      display: flex;
+      flex-direction: column;
+      position: absolute;
+      left: 0;
+      top: 0;
+      height: 100% !important;
+      width: 100%;
+    }
+
+    #canvas-wrap {
+      flex-grow: 1;
+    }
+
+    #canvas {
+      width: 100%;
+      height: 100%;
+    }
+
+    #toolbar {
+      height: 100px;
+    }
+  </style>
+
+  <body style="background-color: #ccc">
+    <div id="container">
+      <div id="canvas-wrap">
+        <canvas id="canvas" style="background-color: #efefef"></canvas>
+      </div>
+
+      <div id="toolbar">
+        <button id="toggleTestLayer">toggleTestLayer</button>
+      </div>
+    </div>
+  </body>
+</html>

+ 51 - 0
src/base/index.ts

@@ -0,0 +1,51 @@
+import { LineArt } from "../../webgl/line-art"
+import { ImageLayer } from "./ImageLayer"
+import { ImageShaders } from "./ImageShaders"
+import { Padding, Scene } from "./Scene"
+import { Triangle } from "./Triangle"
+import { loadImage } from "./utils"
+
+
+document.body.onload = function() {
+  init()
+}
+
+async function init() {
+
+  let canvas = document.querySelector("#canvas") as HTMLCanvasElement
+  let gl = canvas.getContext('webgl2') as WebGL2RenderingContext 
+  
+  let scene = new Scene(gl, window.devicePixelRatio)
+  
+  
+
+  scene.setContentSize(1000, 1000)
+  scene.setContentPadding(new Padding(100, 100, 100, 100))
+  //scene.addLayer(new Triangle(scene))
+  
+
+  let page = await loadImage('/webgl/map.png')
+  //let page = await loadImage('/webgl/page.png')
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR, LineArtShaders.BaseShader))
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR, LineArtShaders.Avg9))
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.NEAREST, LineArtShaders.BicubicLagrange))
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR, LineArtShaders.CommonAvg5))
+  scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR, page.width, page.height,  ImageShaders.CommonGaussianBlur))
+  scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR, page.width, page.height,  ImageShaders.CommonBlur))
+  ///scene.addTestLayer(new ImageLayer(scene, page, gl.LINEAR))
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.NEAREST, LineArtShaders.BicubicHermite))
+  scene.addTestLayer(new ImageLayer(scene, page, gl.NEAREST, page.width, page.height, ImageShaders.BilinearManual))
+  //scene.addTestLayer(new ImageLayer(scene, page, gl.NEAREST))
+  //scene.setScale(20)
+
+
+
+  window.addEventListener('resize', () => {
+    scene.updateViewport()
+  })
+
+
+
+  document.querySelector("#toggleTestLayer")?.addEventListener('click', () => scene.toggleTestLayer())
+  
+}

+ 1468 - 0
src/base/m4.ts

@@ -0,0 +1,1468 @@
+/*
+ * Copyright 2021 GFXFundamentals.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of GFXFundamentals. nor the names of his
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+export namespace m4 {
+
+
+  /**
+   * Various 3d math functions.
+   *
+   * @module webgl-3d-math
+   */
+
+  /**
+   * An array or typed array with 3 values.
+   * @typedef {number[]|TypedArray} Vector3
+   * @memberOf module:webgl-3d-math
+   */
+
+  /**
+   * An array or typed array with 4 values.
+   * @typedef {number[]|TypedArray} Vector4
+   * @memberOf module:webgl-3d-math
+   */
+
+  /**
+   * An array or typed array with 16 values.
+   * @typedef {number[]|TypedArray} Matrix4
+   * @memberOf module:webgl-3d-math
+   */
+
+
+  let MatType = Float32Array;
+  export type Matrix4 = Float32Array
+  export type Vector3 = Float32Array
+  export type Vector4 = Float32Array
+
+  /**
+   * Sets the type this library creates for a Mat4
+   * @param {constructor} Ctor the constructor for the type. Either `Float32Array` or `Array`
+   * @return {constructor} previous constructor for Mat4
+   */
+  //function setDefaultType(Ctor) {
+  //  const OldType = MatType;
+  //  MatType = Ctor;
+  //  return OldType;
+  //}
+
+  /**
+   * Takes two 4-by-4 matrices, a and b, and computes the product in the order
+   * that pre-composes b with a.  In other words, the matrix returned will
+   * transform by b first and then a.  Note this is subtly different from just
+   * multiplying the matrices together.  For given a and b, this function returns
+   * the same object in both row-major and column-major mode.
+   * @param {Matrix4} a A matrix.
+   * @param {Matrix4} b A matrix.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   */
+  export function multiply(a: Matrix4, b: Matrix4, dst: Matrix4 | null = null): Matrix4 {
+    dst = dst || new MatType(16);
+    var b00 = b[0 * 4 + 0];
+    var b01 = b[0 * 4 + 1];
+    var b02 = b[0 * 4 + 2];
+    var b03 = b[0 * 4 + 3];
+    var b10 = b[1 * 4 + 0];
+    var b11 = b[1 * 4 + 1];
+    var b12 = b[1 * 4 + 2];
+    var b13 = b[1 * 4 + 3];
+    var b20 = b[2 * 4 + 0];
+    var b21 = b[2 * 4 + 1];
+    var b22 = b[2 * 4 + 2];
+    var b23 = b[2 * 4 + 3];
+    var b30 = b[3 * 4 + 0];
+    var b31 = b[3 * 4 + 1];
+    var b32 = b[3 * 4 + 2];
+    var b33 = b[3 * 4 + 3];
+    var a00 = a[0 * 4 + 0];
+    var a01 = a[0 * 4 + 1];
+    var a02 = a[0 * 4 + 2];
+    var a03 = a[0 * 4 + 3];
+    var a10 = a[1 * 4 + 0];
+    var a11 = a[1 * 4 + 1];
+    var a12 = a[1 * 4 + 2];
+    var a13 = a[1 * 4 + 3];
+    var a20 = a[2 * 4 + 0];
+    var a21 = a[2 * 4 + 1];
+    var a22 = a[2 * 4 + 2];
+    var a23 = a[2 * 4 + 3];
+    var a30 = a[3 * 4 + 0];
+    var a31 = a[3 * 4 + 1];
+    var a32 = a[3 * 4 + 2];
+    var a33 = a[3 * 4 + 3];
+    dst[0] = b00 * a00 + b01 * a10 + b02 * a20 + b03 * a30;
+    dst[1] = b00 * a01 + b01 * a11 + b02 * a21 + b03 * a31;
+    dst[2] = b00 * a02 + b01 * a12 + b02 * a22 + b03 * a32;
+    dst[3] = b00 * a03 + b01 * a13 + b02 * a23 + b03 * a33;
+    dst[4] = b10 * a00 + b11 * a10 + b12 * a20 + b13 * a30;
+    dst[5] = b10 * a01 + b11 * a11 + b12 * a21 + b13 * a31;
+    dst[6] = b10 * a02 + b11 * a12 + b12 * a22 + b13 * a32;
+    dst[7] = b10 * a03 + b11 * a13 + b12 * a23 + b13 * a33;
+    dst[8] = b20 * a00 + b21 * a10 + b22 * a20 + b23 * a30;
+    dst[9] = b20 * a01 + b21 * a11 + b22 * a21 + b23 * a31;
+    dst[10] = b20 * a02 + b21 * a12 + b22 * a22 + b23 * a32;
+    dst[11] = b20 * a03 + b21 * a13 + b22 * a23 + b23 * a33;
+    dst[12] = b30 * a00 + b31 * a10 + b32 * a20 + b33 * a30;
+    dst[13] = b30 * a01 + b31 * a11 + b32 * a21 + b33 * a31;
+    dst[14] = b30 * a02 + b31 * a12 + b32 * a22 + b33 * a32;
+    dst[15] = b30 * a03 + b31 * a13 + b32 * a23 + b33 * a33;
+    return dst;
+  }
+
+
+  /**
+   * adds 2 vectors3s
+   * @param {Vector3} a a
+   * @param {Vector3} b b
+   * @param {Vector3} dst optional vector3 to store result
+   * @return {Vector3} dst or new Vector3 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function addVectors(a: Vector3, b: Vector3, dst: Vector3): Vector3 {
+    dst = dst || new MatType(3);
+    dst[0] = a[0] + b[0];
+    dst[1] = a[1] + b[1];
+    dst[2] = a[2] + b[2];
+    return dst;
+  }
+
+  /**
+   * subtracts 2 vectors3s
+   * @param {Vector3} a a
+   * @param {Vector3} b b
+   * @param {Vector3} dst optional vector3 to store result
+   * @return {Vector3} dst or new Vector3 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function subtractVectors(a: Vector3, b: Vector3, dst: Vector3 | null = null): Vector3 {
+    dst = dst || new MatType(3);
+    dst[0] = a[0] - b[0];
+    dst[1] = a[1] - b[1];
+    dst[2] = a[2] - b[2];
+    return dst;
+  }
+
+  /**
+   * scale vectors3
+   * @param {Vector3} v vector
+   * @param {Number} s scale
+   * @param {Vector3} dst optional vector3 to store result
+   * @return {Vector3} dst or new Vector3 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function scaleVector(v: Vector3, s: number, dst: Vector3): Vector3 {
+    dst = dst || new MatType(3);
+    dst[0] = v[0] * s;
+    dst[1] = v[1] * s;
+    dst[2] = v[2] * s;
+    return dst;
+  }
+
+  /**
+   * normalizes a vector.
+   * @param {Vector3} v vector to normalize
+   * @param {Vector3} dst optional vector3 to store result
+   * @return {Vector3} dst or new Vector3 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function normalize(v: Vector3, dst: Vector3 | null = null): Vector3 {
+    dst = dst || new MatType(3);
+    var length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+    // make sure we don't divide by 0.
+    if (length > 0.00001) {
+      dst[0] = v[0] / length;
+      dst[1] = v[1] / length;
+      dst[2] = v[2] / length;
+    }
+    return dst;
+  }
+
+  /**
+   * Computes the length of a vector
+   * @param {Vector3} v vector to take length of
+   * @return {number} length of vector
+   */
+  function length(v: Vector3): number {
+    return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+  }
+
+  /**
+   * Computes the length squared of a vector
+   * @param {Vector3} v vector to take length of
+   * @return {number} length sqaured of vector
+   */
+  function lengthSq(v: Vector3): number {
+    return v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
+  }
+
+  /**
+   * Computes the cross product of 2 vectors3s
+   * @param {Vector3} a a
+   * @param {Vector3} b b
+   * @param {Vector3} dst optional vector3 to store result
+   * @return {Vector3} dst or new Vector3 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function cross(a: Vector3, b: Vector3, dst: Vector3 | null = null): Vector3 {
+    dst = dst || new MatType(3);
+    dst[0] = a[1] * b[2] - a[2] * b[1];
+    dst[1] = a[2] * b[0] - a[0] * b[2];
+    dst[2] = a[0] * b[1] - a[1] * b[0];
+    return dst;
+  }
+
+  /**
+   * Computes the dot product of two vectors; assumes both vectors have
+   * three entries.
+   * @param {Vector3} a Operand vector.
+   * @param {Vector3} b Operand vector.
+   * @return {number} dot product
+   * @memberOf module:webgl-3d-math
+   */
+  function dot(a: Vector3, b: Vector3): number {
+    return (a[0] * b[0]) + (a[1] * b[1]) + (a[2] * b[2]);
+  }
+
+  /**
+   * Computes the distance squared between 2 points
+   * @param {Vector3} a
+   * @param {Vector3} b
+   * @return {number} distance squared between a and b
+   */
+  function distanceSq(a: Vector3, b: Vector3): number {
+    const dx = a[0] - b[0];
+    const dy = a[1] - b[1];
+    const dz = a[2] - b[2];
+    return dx * dx + dy * dy + dz * dz;
+  }
+
+  /**
+   * Computes the distance between 2 points
+   * @param {Vector3} a
+   * @param {Vector3} b
+   * @return {number} distance between a and b
+   */
+  function distance(a: Vector3, b: Vector3): number {
+    return Math.sqrt(distanceSq(a, b));
+  }
+
+  /**
+   * Makes an identity matrix.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function identity(dst: Matrix4 | null = null): Matrix4 {
+    dst = dst || new MatType(16);
+
+    dst[0] = 1;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = 1;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = 1;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Transposes a matrix.
+   * @param {Matrix4} m matrix to transpose.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function transpose(m: Matrix4, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    dst[0] = m[0];
+    dst[1] = m[4];
+    dst[2] = m[8];
+    dst[3] = m[12];
+    dst[4] = m[1];
+    dst[5] = m[5];
+    dst[6] = m[9];
+    dst[7] = m[13];
+    dst[8] = m[2];
+    dst[9] = m[6];
+    dst[10] = m[10];
+    dst[11] = m[14];
+    dst[12] = m[3];
+    dst[13] = m[7];
+    dst[14] = m[11];
+    dst[15] = m[15];
+
+    return dst;
+  }
+
+  /**
+   * Creates a lookAt matrix.
+   * This is a world matrix for a camera. In other words it will transform
+   * from the origin to a place and orientation in the world. For a view
+   * matrix take the inverse of this.
+   * @param {Vector3} cameraPosition position of the camera
+   * @param {Vector3} target position of the target
+   * @param {Vector3} up direction
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function lookAt(cameraPosition: Vector3, target: Vector3, up: Vector3, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+    var zAxis = normalize(
+      subtractVectors(cameraPosition, target));
+    var xAxis = normalize(cross(up, zAxis));
+    var yAxis = normalize(cross(zAxis, xAxis));
+
+    dst[0] = xAxis[0];
+    dst[1] = xAxis[1];
+    dst[2] = xAxis[2];
+    dst[3] = 0;
+    dst[4] = yAxis[0];
+    dst[5] = yAxis[1];
+    dst[6] = yAxis[2];
+    dst[7] = 0;
+    dst[8] = zAxis[0];
+    dst[9] = zAxis[1];
+    dst[10] = zAxis[2];
+    dst[11] = 0;
+    dst[12] = cameraPosition[0];
+    dst[13] = cameraPosition[1];
+    dst[14] = cameraPosition[2];
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Computes a 4-by-4 perspective transformation matrix given the angular height
+   * of the frustum, the aspect ratio, and the near and far clipping planes.  The
+   * arguments define a frustum extending in the negative z direction.  The given
+   * angle is the vertical angle of the frustum, and the horizontal angle is
+   * determined to produce the given aspect ratio.  The arguments near and far are
+   * the distances to the near and far clipping planes.  Note that near and far
+   * are not z coordinates, but rather they are distances along the negative
+   * z-axis.  The matrix generated sends the viewing frustum to the unit box.
+   * We assume a unit box extending from -1 to 1 in the x and y dimensions and
+   * from -1 to 1 in the z dimension.
+   * @param {number} fieldOfViewInRadians field of view in y axis.
+   * @param {number} aspect aspect of viewport (width / height)
+   * @param {number} near near Z clipping plane
+   * @param {number} far far Z clipping plane
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function perspective(fieldOfViewInRadians: number, aspect: number, near: number, far: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+    var f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewInRadians);
+    var rangeInv = 1.0 / (near - far);
+
+    dst[0] = f / aspect;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = f;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = (near + far) * rangeInv;
+    dst[11] = -1;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = near * far * rangeInv * 2;
+    dst[15] = 0;
+
+    return dst;
+  }
+
+  /**
+   * Computes a 4-by-4 orthographic projection matrix given the coordinates of the
+   * planes defining the axis-aligned, box-shaped viewing volume.  The matrix
+   * generated sends that box to the unit box.  Note that although left and right
+   * are x coordinates and bottom and top are y coordinates, near and far
+   * are not z coordinates, but rather they are distances along the negative
+   * z-axis.  We assume a unit box extending from -1 to 1 in the x and y
+   * dimensions and from -1 to 1 in the z dimension.
+   * @param {number} left The x coordinate of the left plane of the box.
+   * @param {number} right The x coordinate of the right plane of the box.
+   * @param {number} bottom The y coordinate of the bottom plane of the box.
+   * @param {number} top The y coordinate of the right plane of the box.
+   * @param {number} near The negative z coordinate of the near plane of the box.
+   * @param {number} far The negative z coordinate of the far plane of the box.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function orthographic(left: number, right: number, bottom: number, top: number, near: number, far: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    dst[0] = 2 / (right - left);
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = 2 / (top - bottom);
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = 2 / (near - far);
+    dst[11] = 0;
+    dst[12] = (left + right) / (left - right);
+    dst[13] = (bottom + top) / (bottom - top);
+    dst[14] = (near + far) / (near - far);
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Computes a 4-by-4 perspective transformation matrix given the left, right,
+   * top, bottom, near and far clipping planes. The arguments define a frustum
+   * extending in the negative z direction. The arguments near and far are the
+   * distances to the near and far clipping planes. Note that near and far are not
+   * z coordinates, but rather they are distances along the negative z-axis. The
+   * matrix generated sends the viewing frustum to the unit box. We assume a unit
+   * box extending from -1 to 1 in the x and y dimensions and from -1 to 1 in the z
+   * dimension.
+   * @param {number} left The x coordinate of the left plane of the box.
+   * @param {number} right The x coordinate of the right plane of the box.
+   * @param {number} bottom The y coordinate of the bottom plane of the box.
+   * @param {number} top The y coordinate of the right plane of the box.
+   * @param {number} near The negative z coordinate of the near plane of the box.
+   * @param {number} far The negative z coordinate of the far plane of the box.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function frustum(left: number, right: number, bottom: number, top: number, near: number, far: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    var dx = right - left;
+    var dy = top - bottom;
+    var dz = far - near;
+
+    dst[0] = 2 * near / dx;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = 2 * near / dy;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = (left + right) / dx;
+    dst[9] = (top + bottom) / dy;
+    dst[10] = -(far + near) / dz;
+    dst[11] = -1;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = -2 * near * far / dz;
+    dst[15] = 0;
+
+    return dst;
+  }
+
+  /**
+   * Makes a translation matrix
+   * @param {number} tx x translation.
+   * @param {number} ty y translation.
+   * @param {number} tz z translation.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function translation(tx: number, ty: number, tz: number, dst: Matrix4 | null = null): Matrix4 {
+    dst = dst || new MatType(16);
+
+    dst[0] = 1;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = 1;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = 1;
+    dst[11] = 0;
+    dst[12] = tx;
+    dst[13] = ty;
+    dst[14] = tz;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by translation matrix.
+   * @param {Matrix4} m matrix to multiply
+   * @param {number} tx x translation.
+   * @param {number} ty y translation.
+   * @param {number} tz z translation.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function translate(m: Matrix4, tx: number, ty: number, tz: number, dst: Matrix4): Matrix4 {
+    // This is the optimized version of
+    // return multiply(m, translation(tx, ty, tz), dst);
+    dst = dst || new MatType(16);
+
+    var m00 = m[0];
+    var m01 = m[1];
+    var m02 = m[2];
+    var m03 = m[3];
+    var m10 = m[1 * 4 + 0];
+    var m11 = m[1 * 4 + 1];
+    var m12 = m[1 * 4 + 2];
+    var m13 = m[1 * 4 + 3];
+    var m20 = m[2 * 4 + 0];
+    var m21 = m[2 * 4 + 1];
+    var m22 = m[2 * 4 + 2];
+    var m23 = m[2 * 4 + 3];
+    var m30 = m[3 * 4 + 0];
+    var m31 = m[3 * 4 + 1];
+    var m32 = m[3 * 4 + 2];
+    var m33 = m[3 * 4 + 3];
+
+    if (m !== dst) {
+      dst[0] = m00;
+      dst[1] = m01;
+      dst[2] = m02;
+      dst[3] = m03;
+      dst[4] = m10;
+      dst[5] = m11;
+      dst[6] = m12;
+      dst[7] = m13;
+      dst[8] = m20;
+      dst[9] = m21;
+      dst[10] = m22;
+      dst[11] = m23;
+    }
+
+    dst[12] = m00 * tx + m10 * ty + m20 * tz + m30;
+    dst[13] = m01 * tx + m11 * ty + m21 * tz + m31;
+    dst[14] = m02 * tx + m12 * ty + m22 * tz + m32;
+    dst[15] = m03 * tx + m13 * ty + m23 * tz + m33;
+
+    return dst;
+  }
+
+  /**
+   * Makes an x rotation matrix
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function xRotation(angleInRadians: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[0] = 1;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = c;
+    dst[6] = s;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = -s;
+    dst[10] = c;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by an x rotation matrix
+   * @param {Matrix4} m matrix to multiply
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function xRotate(m: Matrix4, angleInRadians: number, dst: Matrix4): Matrix4 {
+    // this is the optimized version of
+    // return multiply(m, xRotation(angleInRadians), dst);
+    dst = dst || new MatType(16);
+
+    var m10 = m[4];
+    var m11 = m[5];
+    var m12 = m[6];
+    var m13 = m[7];
+    var m20 = m[8];
+    var m21 = m[9];
+    var m22 = m[10];
+    var m23 = m[11];
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[4] = c * m10 + s * m20;
+    dst[5] = c * m11 + s * m21;
+    dst[6] = c * m12 + s * m22;
+    dst[7] = c * m13 + s * m23;
+    dst[8] = c * m20 - s * m10;
+    dst[9] = c * m21 - s * m11;
+    dst[10] = c * m22 - s * m12;
+    dst[11] = c * m23 - s * m13;
+
+    if (m !== dst) {
+      dst[0] = m[0];
+      dst[1] = m[1];
+      dst[2] = m[2];
+      dst[3] = m[3];
+      dst[12] = m[12];
+      dst[13] = m[13];
+      dst[14] = m[14];
+      dst[15] = m[15];
+    }
+
+    return dst;
+  }
+
+  /**
+   * Makes an y rotation matrix
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function yRotation(angleInRadians: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[0] = c;
+    dst[1] = 0;
+    dst[2] = -s;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = 1;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = s;
+    dst[9] = 0;
+    dst[10] = c;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by an y rotation matrix
+   * @param {Matrix4} m matrix to multiply
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function yRotate(m: Matrix4, angleInRadians: number, dst: Matrix4): Matrix4 {
+    // this is the optimized version of
+    // return multiply(m, yRotation(angleInRadians), dst);
+    dst = dst || new MatType(16);
+
+    var m00 = m[0 * 4 + 0];
+    var m01 = m[0 * 4 + 1];
+    var m02 = m[0 * 4 + 2];
+    var m03 = m[0 * 4 + 3];
+    var m20 = m[2 * 4 + 0];
+    var m21 = m[2 * 4 + 1];
+    var m22 = m[2 * 4 + 2];
+    var m23 = m[2 * 4 + 3];
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[0] = c * m00 - s * m20;
+    dst[1] = c * m01 - s * m21;
+    dst[2] = c * m02 - s * m22;
+    dst[3] = c * m03 - s * m23;
+    dst[8] = c * m20 + s * m00;
+    dst[9] = c * m21 + s * m01;
+    dst[10] = c * m22 + s * m02;
+    dst[11] = c * m23 + s * m03;
+
+    if (m !== dst) {
+      dst[4] = m[4];
+      dst[5] = m[5];
+      dst[6] = m[6];
+      dst[7] = m[7];
+      dst[12] = m[12];
+      dst[13] = m[13];
+      dst[14] = m[14];
+      dst[15] = m[15];
+    }
+
+    return dst;
+  }
+
+  /**
+   * Makes an z rotation matrix
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function zRotation(angleInRadians: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[0] = c;
+    dst[1] = s;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = -s;
+    dst[5] = c;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = 1;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by an z rotation matrix
+   * @param {Matrix4} m matrix to multiply
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function zRotate(m: Matrix4, angleInRadians: number, dst: Matrix4): Matrix4 {
+    // This is the optimized version of
+    // return multiply(m, zRotation(angleInRadians), dst);
+    dst = dst || new MatType(16);
+
+    var m00 = m[0 * 4 + 0];
+    var m01 = m[0 * 4 + 1];
+    var m02 = m[0 * 4 + 2];
+    var m03 = m[0 * 4 + 3];
+    var m10 = m[1 * 4 + 0];
+    var m11 = m[1 * 4 + 1];
+    var m12 = m[1 * 4 + 2];
+    var m13 = m[1 * 4 + 3];
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+
+    dst[0] = c * m00 + s * m10;
+    dst[1] = c * m01 + s * m11;
+    dst[2] = c * m02 + s * m12;
+    dst[3] = c * m03 + s * m13;
+    dst[4] = c * m10 - s * m00;
+    dst[5] = c * m11 - s * m01;
+    dst[6] = c * m12 - s * m02;
+    dst[7] = c * m13 - s * m03;
+
+    if (m !== dst) {
+      dst[8] = m[8];
+      dst[9] = m[9];
+      dst[10] = m[10];
+      dst[11] = m[11];
+      dst[12] = m[12];
+      dst[13] = m[13];
+      dst[14] = m[14];
+      dst[15] = m[15];
+    }
+
+    return dst;
+  }
+
+  /**
+   * Makes an rotation matrix around an arbitrary axis
+   * @param {Vector3} axis axis to rotate around
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function axisRotation(axis: Vector3, angleInRadians: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    var x = axis[0];
+    var y = axis[1];
+    var z = axis[2];
+    var n = Math.sqrt(x * x + y * y + z * z);
+    x /= n;
+    y /= n;
+    z /= n;
+    var xx = x * x;
+    var yy = y * y;
+    var zz = z * z;
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+    var oneMinusCosine = 1 - c;
+
+    dst[0] = xx + (1 - xx) * c;
+    dst[1] = x * y * oneMinusCosine + z * s;
+    dst[2] = x * z * oneMinusCosine - y * s;
+    dst[3] = 0;
+    dst[4] = x * y * oneMinusCosine - z * s;
+    dst[5] = yy + (1 - yy) * c;
+    dst[6] = y * z * oneMinusCosine + x * s;
+    dst[7] = 0;
+    dst[8] = x * z * oneMinusCosine + y * s;
+    dst[9] = y * z * oneMinusCosine - x * s;
+    dst[10] = zz + (1 - zz) * c;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by an axis rotation matrix
+   * @param {Matrix4} m matrix to multiply
+   * @param {Vector3} axis axis to rotate around
+   * @param {number} angleInRadians amount to rotate
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  function axisRotate(m: Matrix4, axis: Vector3, angleInRadians: number, dst: Matrix4): Matrix4 {
+    // This is the optimized version of
+    // return multiply(m, axisRotation(axis, angleInRadians), dst);
+    dst = dst || new MatType(16);
+
+    var x = axis[0];
+    var y = axis[1];
+    var z = axis[2];
+    var n = Math.sqrt(x * x + y * y + z * z);
+    x /= n;
+    y /= n;
+    z /= n;
+    var xx = x * x;
+    var yy = y * y;
+    var zz = z * z;
+    var c = Math.cos(angleInRadians);
+    var s = Math.sin(angleInRadians);
+    var oneMinusCosine = 1 - c;
+
+    var r00 = xx + (1 - xx) * c;
+    var r01 = x * y * oneMinusCosine + z * s;
+    var r02 = x * z * oneMinusCosine - y * s;
+    var r10 = x * y * oneMinusCosine - z * s;
+    var r11 = yy + (1 - yy) * c;
+    var r12 = y * z * oneMinusCosine + x * s;
+    var r20 = x * z * oneMinusCosine + y * s;
+    var r21 = y * z * oneMinusCosine - x * s;
+    var r22 = zz + (1 - zz) * c;
+
+    var m00 = m[0];
+    var m01 = m[1];
+    var m02 = m[2];
+    var m03 = m[3];
+    var m10 = m[4];
+    var m11 = m[5];
+    var m12 = m[6];
+    var m13 = m[7];
+    var m20 = m[8];
+    var m21 = m[9];
+    var m22 = m[10];
+    var m23 = m[11];
+
+    dst[0] = r00 * m00 + r01 * m10 + r02 * m20;
+    dst[1] = r00 * m01 + r01 * m11 + r02 * m21;
+    dst[2] = r00 * m02 + r01 * m12 + r02 * m22;
+    dst[3] = r00 * m03 + r01 * m13 + r02 * m23;
+    dst[4] = r10 * m00 + r11 * m10 + r12 * m20;
+    dst[5] = r10 * m01 + r11 * m11 + r12 * m21;
+    dst[6] = r10 * m02 + r11 * m12 + r12 * m22;
+    dst[7] = r10 * m03 + r11 * m13 + r12 * m23;
+    dst[8] = r20 * m00 + r21 * m10 + r22 * m20;
+    dst[9] = r20 * m01 + r21 * m11 + r22 * m21;
+    dst[10] = r20 * m02 + r21 * m12 + r22 * m22;
+    dst[11] = r20 * m03 + r21 * m13 + r22 * m23;
+
+    if (m !== dst) {
+      dst[12] = m[12];
+      dst[13] = m[13];
+      dst[14] = m[14];
+      dst[15] = m[15];
+    }
+
+    return dst;
+  }
+
+  /**
+   * Makes a scale matrix
+   * @param {number} sx x scale.
+   * @param {number} sy y scale.
+   * @param {number} sz z scale.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function scaling(sx: number, sy: number, sz: number, dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    dst[0] = sx;
+    dst[1] = 0;
+    dst[2] = 0;
+    dst[3] = 0;
+    dst[4] = 0;
+    dst[5] = sy;
+    dst[6] = 0;
+    dst[7] = 0;
+    dst[8] = 0;
+    dst[9] = 0;
+    dst[10] = sz;
+    dst[11] = 0;
+    dst[12] = 0;
+    dst[13] = 0;
+    dst[14] = 0;
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  /**
+   * Multiply by a scaling matrix
+   * @param {Matrix4} m matrix to multiply
+   * @param {number} sx x scale.
+   * @param {number} sy y scale.
+   * @param {number} sz z scale.
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function scale(m: Matrix4, sx: number, sy: number, sz: number, dst: Matrix4): Matrix4 {
+    // This is the optimized version of
+    // return multiply(m, scaling(sx, sy, sz), dst);
+    dst = dst || new MatType(16);
+
+    dst[0] = sx * m[0 * 4 + 0];
+    dst[1] = sx * m[0 * 4 + 1];
+    dst[2] = sx * m[0 * 4 + 2];
+    dst[3] = sx * m[0 * 4 + 3];
+    dst[4] = sy * m[1 * 4 + 0];
+    dst[5] = sy * m[1 * 4 + 1];
+    dst[6] = sy * m[1 * 4 + 2];
+    dst[7] = sy * m[1 * 4 + 3];
+    dst[8] = sz * m[2 * 4 + 0];
+    dst[9] = sz * m[2 * 4 + 1];
+    dst[10] = sz * m[2 * 4 + 2];
+    dst[11] = sz * m[2 * 4 + 3];
+
+    if (m !== dst) {
+      dst[12] = m[12];
+      dst[13] = m[13];
+      dst[14] = m[14];
+      dst[15] = m[15];
+    }
+
+    return dst;
+  }
+
+  /**
+   * creates a matrix from translation, quaternion, scale
+   * @param {Number[]} translation [x, y, z] translation
+   * @param {Number[]} quaternion [x, y, z, z] quaternion rotation
+   * @param {Number[]} scale [x, y, z] scale
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   */
+  function compose(translation: number[], quaternion: number[], scale: number[], dst: Matrix4): Matrix4 {
+    dst = dst || new MatType(16);
+
+    const x = quaternion[0];
+    const y = quaternion[1];
+    const z = quaternion[2];
+    const w = quaternion[3];
+
+    const x2 = x + x;
+    const y2 = y + y;
+    const z2 = z + z;
+
+    const xx = x * x2;
+    const xy = x * y2;
+    const xz = x * z2;
+
+    const yy = y * y2;
+    const yz = y * z2;
+    const zz = z * z2;
+
+    const wx = w * x2;
+    const wy = w * y2;
+    const wz = w * z2;
+
+    const sx = scale[0];
+    const sy = scale[1];
+    const sz = scale[2];
+
+    dst[0] = (1 - (yy + zz)) * sx;
+    dst[1] = (xy + wz) * sx;
+    dst[2] = (xz - wy) * sx;
+    dst[3] = 0;
+
+    dst[4] = (xy - wz) * sy;
+    dst[5] = (1 - (xx + zz)) * sy;
+    dst[6] = (yz + wx) * sy;
+    dst[7] = 0;
+
+    dst[8] = (xz + wy) * sz;
+    dst[9] = (yz - wx) * sz;
+    dst[10] = (1 - (xx + yy)) * sz;
+    dst[11] = 0;
+
+    dst[12] = translation[0];
+    dst[13] = translation[1];
+    dst[14] = translation[2];
+    dst[15] = 1;
+
+    return dst;
+  }
+
+  function quatFromRotationMatrix(m: Matrix4, dst: Matrix4) {
+    // http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/index.htm
+
+    // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled)
+    const m11 = m[0];
+    const m12 = m[4];
+    const m13 = m[8];
+    const m21 = m[1];
+    const m22 = m[5];
+    const m23 = m[9];
+    const m31 = m[2];
+    const m32 = m[6];
+    const m33 = m[10];
+
+    const trace = m11 + m22 + m33;
+
+    if (trace > 0) {
+      const s = 0.5 / Math.sqrt(trace + 1);
+      dst[3] = 0.25 / s;
+      dst[0] = (m32 - m23) * s;
+      dst[1] = (m13 - m31) * s;
+      dst[2] = (m21 - m12) * s;
+    } else if (m11 > m22 && m11 > m33) {
+      const s = 2 * Math.sqrt(1 + m11 - m22 - m33);
+      dst[3] = (m32 - m23) / s;
+      dst[0] = 0.25 * s;
+      dst[1] = (m12 + m21) / s;
+      dst[2] = (m13 + m31) / s;
+    } else if (m22 > m33) {
+      const s = 2 * Math.sqrt(1 + m22 - m11 - m33);
+      dst[3] = (m13 - m31) / s;
+      dst[0] = (m12 + m21) / s;
+      dst[1] = 0.25 * s;
+      dst[2] = (m23 + m32) / s;
+    } else {
+      const s = 2 * Math.sqrt(1 + m33 - m11 - m22);
+      dst[3] = (m21 - m12) / s;
+      dst[0] = (m13 + m31) / s;
+      dst[1] = (m23 + m32) / s;
+      dst[2] = 0.25 * s;
+    }
+  }
+
+  function decompose(mat: Matrix4, translation: Matrix4, quaternion: Matrix4, scale: Matrix4) {
+    let sx = length(mat.slice(0, 3));
+    const sy = length(mat.slice(4, 7));
+    const sz = length(mat.slice(8, 11));
+
+    // if determinate is negative, we need to invert one scale
+    const det = determinate(mat);
+    if (det < 0) {
+      sx = -sx;
+    }
+
+    translation[0] = mat[12];
+    translation[1] = mat[13];
+    translation[2] = mat[14];
+
+    // scale the rotation part
+    const matrix = copy(mat);
+
+    const invSX = 1 / sx;
+    const invSY = 1 / sy;
+    const invSZ = 1 / sz;
+
+    matrix[0] *= invSX;
+    matrix[1] *= invSX;
+    matrix[2] *= invSX;
+
+    matrix[4] *= invSY;
+    matrix[5] *= invSY;
+    matrix[6] *= invSY;
+
+    matrix[8] *= invSZ;
+    matrix[9] *= invSZ;
+    matrix[10] *= invSZ;
+
+    quatFromRotationMatrix(matrix, quaternion);
+
+    scale[0] = sx;
+    scale[1] = sy;
+    scale[2] = sz;
+  }
+
+  function determinate(m: Matrix4): number {
+    var m00 = m[0 * 4 + 0];
+    var m01 = m[0 * 4 + 1];
+    var m02 = m[0 * 4 + 2];
+    var m03 = m[0 * 4 + 3];
+    var m10 = m[1 * 4 + 0];
+    var m11 = m[1 * 4 + 1];
+    var m12 = m[1 * 4 + 2];
+    var m13 = m[1 * 4 + 3];
+    var m20 = m[2 * 4 + 0];
+    var m21 = m[2 * 4 + 1];
+    var m22 = m[2 * 4 + 2];
+    var m23 = m[2 * 4 + 3];
+    var m30 = m[3 * 4 + 0];
+    var m31 = m[3 * 4 + 1];
+    var m32 = m[3 * 4 + 2];
+    var m33 = m[3 * 4 + 3];
+    var tmp_0 = m22 * m33;
+    var tmp_1 = m32 * m23;
+    var tmp_2 = m12 * m33;
+    var tmp_3 = m32 * m13;
+    var tmp_4 = m12 * m23;
+    var tmp_5 = m22 * m13;
+    var tmp_6 = m02 * m33;
+    var tmp_7 = m32 * m03;
+    var tmp_8 = m02 * m23;
+    var tmp_9 = m22 * m03;
+    var tmp_10 = m02 * m13;
+    var tmp_11 = m12 * m03;
+
+    var t0 = (tmp_0 * m11 + tmp_3 * m21 + tmp_4 * m31) -
+      (tmp_1 * m11 + tmp_2 * m21 + tmp_5 * m31);
+    var t1 = (tmp_1 * m01 + tmp_6 * m21 + tmp_9 * m31) -
+      (tmp_0 * m01 + tmp_7 * m21 + tmp_8 * m31);
+    var t2 = (tmp_2 * m01 + tmp_7 * m11 + tmp_10 * m31) -
+      (tmp_3 * m01 + tmp_6 * m11 + tmp_11 * m31);
+    var t3 = (tmp_5 * m01 + tmp_8 * m11 + tmp_11 * m21) -
+      (tmp_4 * m01 + tmp_9 * m11 + tmp_10 * m21);
+
+    return 1.0 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3);
+  }
+
+  /**
+   * Computes the inverse of a matrix.
+   * @param {Matrix4} m matrix to compute inverse of
+   * @param {Matrix4} [dst] optional matrix to store result
+   * @return {Matrix4} dst or a new matrix if none provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function inverse(m: Matrix4, dst: Matrix4 | null = null): Matrix4 {
+    dst = dst || new MatType(16);
+    var m00 = m[0 * 4 + 0];
+    var m01 = m[0 * 4 + 1];
+    var m02 = m[0 * 4 + 2];
+    var m03 = m[0 * 4 + 3];
+    var m10 = m[1 * 4 + 0];
+    var m11 = m[1 * 4 + 1];
+    var m12 = m[1 * 4 + 2];
+    var m13 = m[1 * 4 + 3];
+    var m20 = m[2 * 4 + 0];
+    var m21 = m[2 * 4 + 1];
+    var m22 = m[2 * 4 + 2];
+    var m23 = m[2 * 4 + 3];
+    var m30 = m[3 * 4 + 0];
+    var m31 = m[3 * 4 + 1];
+    var m32 = m[3 * 4 + 2];
+    var m33 = m[3 * 4 + 3];
+    var tmp_0 = m22 * m33;
+    var tmp_1 = m32 * m23;
+    var tmp_2 = m12 * m33;
+    var tmp_3 = m32 * m13;
+    var tmp_4 = m12 * m23;
+    var tmp_5 = m22 * m13;
+    var tmp_6 = m02 * m33;
+    var tmp_7 = m32 * m03;
+    var tmp_8 = m02 * m23;
+    var tmp_9 = m22 * m03;
+    var tmp_10 = m02 * m13;
+    var tmp_11 = m12 * m03;
+    var tmp_12 = m20 * m31;
+    var tmp_13 = m30 * m21;
+    var tmp_14 = m10 * m31;
+    var tmp_15 = m30 * m11;
+    var tmp_16 = m10 * m21;
+    var tmp_17 = m20 * m11;
+    var tmp_18 = m00 * m31;
+    var tmp_19 = m30 * m01;
+    var tmp_20 = m00 * m21;
+    var tmp_21 = m20 * m01;
+    var tmp_22 = m00 * m11;
+    var tmp_23 = m10 * m01;
+
+    var t0 = (tmp_0 * m11 + tmp_3 * m21 + tmp_4 * m31) -
+      (tmp_1 * m11 + tmp_2 * m21 + tmp_5 * m31);
+    var t1 = (tmp_1 * m01 + tmp_6 * m21 + tmp_9 * m31) -
+      (tmp_0 * m01 + tmp_7 * m21 + tmp_8 * m31);
+    var t2 = (tmp_2 * m01 + tmp_7 * m11 + tmp_10 * m31) -
+      (tmp_3 * m01 + tmp_6 * m11 + tmp_11 * m31);
+    var t3 = (tmp_5 * m01 + tmp_8 * m11 + tmp_11 * m21) -
+      (tmp_4 * m01 + tmp_9 * m11 + tmp_10 * m21);
+
+    var d = 1.0 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3);
+
+    dst[0] = d * t0;
+    dst[1] = d * t1;
+    dst[2] = d * t2;
+    dst[3] = d * t3;
+    dst[4] = d * ((tmp_1 * m10 + tmp_2 * m20 + tmp_5 * m30) -
+      (tmp_0 * m10 + tmp_3 * m20 + tmp_4 * m30));
+    dst[5] = d * ((tmp_0 * m00 + tmp_7 * m20 + tmp_8 * m30) -
+      (tmp_1 * m00 + tmp_6 * m20 + tmp_9 * m30));
+    dst[6] = d * ((tmp_3 * m00 + tmp_6 * m10 + tmp_11 * m30) -
+      (tmp_2 * m00 + tmp_7 * m10 + tmp_10 * m30));
+    dst[7] = d * ((tmp_4 * m00 + tmp_9 * m10 + tmp_10 * m20) -
+      (tmp_5 * m00 + tmp_8 * m10 + tmp_11 * m20));
+    dst[8] = d * ((tmp_12 * m13 + tmp_15 * m23 + tmp_16 * m33) -
+      (tmp_13 * m13 + tmp_14 * m23 + tmp_17 * m33));
+    dst[9] = d * ((tmp_13 * m03 + tmp_18 * m23 + tmp_21 * m33) -
+      (tmp_12 * m03 + tmp_19 * m23 + tmp_20 * m33));
+    dst[10] = d * ((tmp_14 * m03 + tmp_19 * m13 + tmp_22 * m33) -
+      (tmp_15 * m03 + tmp_18 * m13 + tmp_23 * m33));
+    dst[11] = d * ((tmp_17 * m03 + tmp_20 * m13 + tmp_23 * m23) -
+      (tmp_16 * m03 + tmp_21 * m13 + tmp_22 * m23));
+    dst[12] = d * ((tmp_14 * m22 + tmp_17 * m32 + tmp_13 * m12) -
+      (tmp_16 * m32 + tmp_12 * m12 + tmp_15 * m22));
+    dst[13] = d * ((tmp_20 * m32 + tmp_12 * m02 + tmp_19 * m22) -
+      (tmp_18 * m22 + tmp_21 * m32 + tmp_13 * m02));
+    dst[14] = d * ((tmp_18 * m12 + tmp_23 * m32 + tmp_15 * m02) -
+      (tmp_22 * m32 + tmp_14 * m02 + tmp_19 * m12));
+    dst[15] = d * ((tmp_22 * m22 + tmp_16 * m02 + tmp_21 * m12) -
+      (tmp_20 * m12 + tmp_23 * m22 + tmp_17 * m02));
+
+    return dst;
+  }
+
+  /**
+   * Takes a  matrix and a vector with 4 entries, transforms that vector by
+   * the matrix, and returns the result as a vector with 4 entries.
+   * @param {Matrix4} m The matrix.
+   * @param {Vector4} v The point in homogenous coordinates.
+   * @param {Vector4} dst optional vector4 to store result
+   * @return {Vector4} dst or new Vector4 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function transformVector(m: Matrix4, v: Vector4, dst: Vector4): Vector4 {
+    dst = dst || new MatType(4);
+    for (var i = 0; i < 4; ++i) {
+      dst[i] = 0.0;
+      for (var j = 0; j < 4; ++j) {
+        dst[i] += v[j] * m[j * 4 + i];
+      }
+    }
+    return dst;
+  }
+
+  /**
+   * Takes a 4-by-4 matrix and a vector with 3 entries,
+   * interprets the vector as a point, transforms that point by the matrix, and
+   * returns the result as a vector with 3 entries.
+   * @param {Matrix4} m The matrix.
+   * @param {Vector3} v The point.
+   * @param {Vector4} dst optional vector4 to store result
+   * @return {Vector4} dst or new Vector4 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  export function transformPoint(m: Matrix4, v: Vector3, dst: Vector4 | null = null): Vector4 {
+    dst = dst || new MatType(3);
+    var v0 = v[0];
+    var v1 = v[1];
+    var v2 = v[2];
+    var d = v0 * m[0 * 4 + 3] + v1 * m[1 * 4 + 3] + v2 * m[2 * 4 + 3] + m[3 * 4 + 3];
+
+    dst[0] = (v0 * m[0 * 4 + 0] + v1 * m[1 * 4 + 0] + v2 * m[2 * 4 + 0] + m[3 * 4 + 0]) / d;
+    dst[1] = (v0 * m[0 * 4 + 1] + v1 * m[1 * 4 + 1] + v2 * m[2 * 4 + 1] + m[3 * 4 + 1]) / d;
+    dst[2] = (v0 * m[0 * 4 + 2] + v1 * m[1 * 4 + 2] + v2 * m[2 * 4 + 2] + m[3 * 4 + 2]) / d;
+
+    return dst;
+  }
+
+  /**
+   * Takes a 4-by-4 matrix and a vector with 3 entries, interprets the vector as a
+   * direction, transforms that direction by the matrix, and returns the result;
+   * assumes the transformation of 3-dimensional space represented by the matrix
+   * is parallel-preserving, i.e. any combination of rotation, scaling and
+   * translation, but not a perspective distortion. Returns a vector with 3
+   * entries.
+   * @param {Matrix4} m The matrix.
+   * @param {Vector3} v The direction.
+   * @param {Vector4} dst optional vector4 to store result
+   * @return {Vector4} dst or new Vector4 if not provided
+   * @memberOf module:webgl-3d-math
+   */
+  function transformDirection(m: Matrix4, v: Vector3, dst: Vector4): Vector4 {
+    dst = dst || new MatType(3);
+
+    var v0 = v[0];
+    var v1 = v[1];
+    var v2 = v[2];
+
+    dst[0] = v0 * m[0 * 4 + 0] + v1 * m[1 * 4 + 0] + v2 * m[2 * 4 + 0];
+    dst[1] = v0 * m[0 * 4 + 1] + v1 * m[1 * 4 + 1] + v2 * m[2 * 4 + 1];
+    dst[2] = v0 * m[0 * 4 + 2] + v1 * m[1 * 4 + 2] + v2 * m[2 * 4 + 2];
+
+    return dst;
+  }
+
+  /**
+   * Takes a 4-by-4 matrix m and a vector v with 3 entries, interprets the vector
+   * as a normal to a surface, and computes a vector which is normal upon
+   * transforming that surface by the matrix. The effect of this function is the
+   * same as transforming v (as a direction) by the inverse-transpose of m.  This
+   * function assumes the transformation of 3-dimensional space represented by the
+   * matrix is parallel-preserving, i.e. any combination of rotation, scaling and
+   * translation, but not a perspective distortion.  Returns a vector with 3
+   * entries.
+   * @param {Matrix4} m The matrix.
+   * @param {Vector3} v The normal.
+   * @param {Vector3} [dst] The direction.
+   * @return {Vector3} The transformed direction.
+   * @memberOf module:webgl-3d-math
+   */
+  function transformNormal(m: Matrix4, v: Vector3, dst: Vector3): Vector3 {
+    dst = dst || new MatType(3);
+    var mi = inverse(m);
+    var v0 = v[0];
+    var v1 = v[1];
+    var v2 = v[2];
+
+    dst[0] = v0 * mi[0 * 4 + 0] + v1 * mi[0 * 4 + 1] + v2 * mi[0 * 4 + 2];
+    dst[1] = v0 * mi[1 * 4 + 0] + v1 * mi[1 * 4 + 1] + v2 * mi[1 * 4 + 2];
+    dst[2] = v0 * mi[2 * 4 + 0] + v1 * mi[2 * 4 + 1] + v2 * mi[2 * 4 + 2];
+
+    return dst;
+  }
+
+  export function copy(src: Matrix4, dst: Matrix4 | null = null) {
+    dst = dst || new MatType(16);
+
+    dst[0] = src[0];
+    dst[1] = src[1];
+    dst[2] = src[2];
+    dst[3] = src[3];
+    dst[4] = src[4];
+    dst[5] = src[5];
+    dst[6] = src[6];
+    dst[7] = src[7];
+    dst[8] = src[8];
+    dst[9] = src[9];
+    dst[10] = src[10];
+    dst[11] = src[11];
+    dst[12] = src[12];
+    dst[13] = src[13];
+    dst[14] = src[14];
+    dst[15] = src[15];
+
+    return dst;
+  }
+
+
+
+
+
+
+  export function projection( width: number, height: number, matrix : Matrix4 | null = null) : Matrix4 {
+    matrix = identity(matrix)
+    matrix[0] = 2. / width
+    matrix[5] = -2. / height
+    matrix[12] = -1
+    matrix[13] = 1
+    return matrix
+  }
+
+
+
+
+  export function projectionNoflipY( width: number, height: number, matrix : Matrix4 | null = null) : Matrix4 {
+    matrix = identity(matrix)
+    matrix[0] = 2. / width
+    matrix[5] = 2. / height
+    matrix[12] = -1
+    matrix[13] = -1
+    return matrix
+  }
+
+
+
+  
+
+  export function scaleAt(scaleX : number, scaleY : number, focusX : number, focusY : number) : Matrix4 {
+    let matrix = identity()
+    matrix[0] = scaleX
+    matrix[5] = scaleY
+    matrix[12] = -scaleX * focusX + focusX
+    matrix[13] = -scaleY * focusY + focusY
+    return matrix
+  }
+
+
+  export function lerp(fromMat : Matrix4, toMat : Matrix4, outMat : Matrix4, t : number) {
+    for(var i=0; i<16; i++) {
+      outMat[i] = fromMat[i] + (toMat[i] - fromMat[i]) * t
+    }
+  }
+
+
+
+}

+ 181 - 0
src/base/utils.ts

@@ -0,0 +1,181 @@
+
+
+export function createProgramFromScripts(gl: WebGLRenderingContext,) {
+
+}
+
+
+
+// 创建着色器方法,输入参数:渲染上下文,着色器类型,数据源
+export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
+  var shader = gl.createShader(type)!; // 创建着色器对象
+  gl.shaderSource(shader, source); // 提供数据源
+  gl.compileShader(shader); // 编译 -> 生成着色器
+  var success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
+  if (success) {
+    return shader;
+  }
+
+  console.log("error", gl.getShaderInfoLog(shader));
+  gl.deleteShader(shader);
+}
+
+export function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram | undefined {
+  var program = gl.createProgram()!;
+  gl.attachShader(program, vertexShader);
+  gl.attachShader(program, fragmentShader);
+  gl.linkProgram(program);
+  var success = gl.getProgramParameter(program, gl.LINK_STATUS);
+  if (success) {
+    return program;
+  }
+
+  console.log("error", gl.getProgramInfoLog(program));
+  gl.deleteProgram(program);
+}
+
+
+
+
+
+// export function loadImage(canvas : any, url: string): Promise<HTMLImageElement> {
+//   return new Promise((done, reject) => {
+//     let image = canvas.createImage();
+//     image.src = url;
+//     image.onload = () => done(image)
+//     image.onerror = reject
+//   })
+// }
+
+export function loadImage(url: string): Promise<HTMLImageElement> {
+  return new Promise((done, reject) => {
+    let image = new Image();
+    image.src = url;
+    image.onload = () => done(image)
+    image.onerror = reject
+  })
+}
+
+
+
+export function createScaledImage(image: HTMLImageElement | HTMLCanvasElement, width: number, height: number) {
+  let canvas = document.createElement('canvas');
+  canvas.width = width
+  canvas.height = height
+  let ctx = canvas.getContext('2d')!
+  ctx.imageSmoothingEnabled = false;
+  ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height)
+  return canvas
+}
+
+
+
+export function parseColorInt(color: number): Float32Array {
+  let out = new Float32Array(4)
+  out[0] = color >> 24 & 0xff
+  out[1] = color >> 16 & 0xff
+  out[2] = color >> 8 & 0xff
+  out[3] = color & 0xff
+  return out
+}
+
+
+
+export function coerceIn(n: number, min: number, max: number): number {
+  if (n < min) return min
+  else if (n > max) return max
+  else return n
+}
+
+export function randomColor() {
+  return 'rgba(' + Math.round(Math.random() * 255) + ',' + Math.round(Math.random() * 255) + ',' + Math.round(Math.random() * 255) + ',' + 1 + ')';
+}
+
+
+// rgba 转成 hsl 颜色
+export function rgbaToHsl(rgba: number[]): number[] {
+  const [r, g, b, a] = rgba.map(val => val / 255);
+  const max = Math.max(r, g, b);
+  const min = Math.min(r, g, b);
+  let h, s, l = (max + min) / 2;
+
+  if (max === min) {
+    h = s = 0; // achromatic
+  } else {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    switch (max) {
+      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+      case g: h = (b - r) / d + 2; break;
+      case b: h = (r - g) / d + 4; break;
+    }
+    h! /= 6;
+  }
+
+  return [h! * 360, s * 100, l * 100, a];
+}
+
+// hsl 转成 rgba
+export function hslToRgba(hsl: number[]): number[] {
+  let [h, s, l, a] = hsl;
+  s /= 100;
+  l /= 100;
+
+  const c = (1 - Math.abs(2 * l - 1)) * s;
+  const x = c * (1 - Math.abs((h / 60) % 2 - 1));
+  const m = l - c / 2;
+  let r, g, b;
+
+  if (0 <= h && h < 60) { r = c; g = x; b = 0; }
+  else if (60 <= h && h < 120) { r = x; g = c; b = 0; }
+  else if (120 <= h && h < 180) { r = 0; g = c; b = x; }
+  else if (180 <= h && h < 240) { r = 0; g = x; b = c; }
+  else if (240 <= h && h < 300) { r = x; g = 0; b = c; }
+  else if (300 <= h && h < 360) { r = c; g = 0; b = x; }
+
+  return [
+    Math.round((r! + m) * 255),
+    Math.round((g! + m) * 255),
+    Math.round((b! + m) * 255),
+    a
+  ];
+}
+
+// 字符串rgba转成数组形式
+export function rgbaStringToArray(rgbaString: string) {
+  const regex = /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/;
+  const match = rgbaString.match(regex);
+  if (match) {
+    return match.slice(1).map(Number);
+  }
+  return null;
+}
+
+// 数组形式rgba转成字符串形式
+export function rgbaArrayToString(rgbaArray: number[]) {
+  return `rgba(${rgbaArray[0]}, ${rgbaArray[1]}, ${rgbaArray[2]}, ${rgbaArray[3]})`;
+}
+
+// 给定颜色, 生成相似颜色数组
+export function generateSimilarColors(rgbaString: string, n: number) {
+  const rgba = rgbaStringToArray(rgbaString);
+  if (!rgba) {
+    return [];
+  }
+
+  const hsl = rgbaToHsl(rgba);
+  const similarColors = [];
+
+  for (let i = 0; i < n; i++) {
+    const hueOffset = Math.random() * 60 - 30; // 随机色相偏移量 (-30 到 30)
+    const saturationOffset = Math.random() * 40 - 20; // 随机饱和度偏移量 (-20 到 20)
+    const lightnessOffset = Math.random() * 40 - 20; // 随机亮度偏移量 (-20 到 20)
+
+    const newHue = (hsl[0] + hueOffset) % 360;
+    const newSaturation = Math.max(0, Math.min(100, hsl[1] + saturationOffset));
+    const newLightness = Math.max(0, Math.min(100, hsl[2] + lightnessOffset));
+    similarColors.push(rgbaArrayToString(hslToRgba([newHue, newSaturation, newLightness, hsl[3]])));
+  }
+
+  return similarColors;
+}

+ 1195 - 0
src/base/webgl-debug.kk

@@ -0,0 +1,1195 @@
+/*
+** Copyright (c) 2012 The Khronos Group Inc.
+**
+** Permission is hereby granted, free of charge, to any person obtaining a
+** copy of this software and/or associated documentation files (the
+** "Materials"), to deal in the Materials without restriction, including
+** without limitation the rights to use, copy, modify, merge, publish,
+** distribute, sublicense, and/or sell copies of the Materials, and to
+** permit persons to whom the Materials are furnished to do so, subject to
+** the following conditions:
+**
+** The above copyright notice and this permission notice shall be included
+** in all copies or substantial portions of the Materials.
+**
+** THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+** MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.
+*/
+
+// Various functions for helping debug WebGL apps.
+
+export const WebGLDebugUtils = function() {
+
+/**
+ * Wrapped logging function.
+ * @param {string} msg Message to log.
+ */
+var log = function(msg: string) {
+  if (window.console && window.console.log) {
+    window.console.log(msg);
+  }
+};
+
+/**
+ * Wrapped error logging function.
+ * @param {string} msg Message to log.
+ */
+var error = function(msg : string) {
+  if (window.console && window.console.error) {
+    window.console.error(msg);
+  } else {
+    log(msg);
+  }
+};
+
+
+/**
+ * Which arguments are enums based on the number of arguments to the function.
+ * So
+ *    'texImage2D': {
+ *       9: { 0:true, 2:true, 6:true, 7:true },
+ *       6: { 0:true, 2:true, 3:true, 4:true },
+ *    },
+ *
+ * means if there are 9 arguments then 6 and 7 are enums, if there are 6
+ * arguments 3 and 4 are enums
+ *
+ * @type {!Object.<number, !Object.<number, string>}
+ */
+var glValidEnumContexts = {
+  // Generic setters and getters
+
+  'enable': {1: { 0:true }},
+  'disable': {1: { 0:true }},
+  'getParameter': {1: { 0:true }},
+
+  // Rendering
+
+  'drawArrays': {3:{ 0:true }},
+  'drawElements': {4:{ 0:true, 2:true }},
+
+  // Shaders
+
+  'createShader': {1: { 0:true }},
+  'getShaderParameter': {2: { 1:true }},
+  'getProgramParameter': {2: { 1:true }},
+  'getShaderPrecisionFormat': {2: { 0: true, 1:true }},
+
+  // Vertex attributes
+
+  'getVertexAttrib': {2: { 1:true }},
+  'vertexAttribPointer': {6: { 2:true }},
+
+  // Textures
+
+  'bindTexture': {2: { 0:true }},
+  'activeTexture': {1: { 0:true }},
+  'getTexParameter': {2: { 0:true, 1:true }},
+  'texParameterf': {3: { 0:true, 1:true }},
+  'texParameteri': {3: { 0:true, 1:true, 2:true }},
+  // texImage2D and texSubImage2D are defined below with WebGL 2 entrypoints
+  'copyTexImage2D': {8: { 0:true, 2:true }},
+  'copyTexSubImage2D': {8: { 0:true }},
+  'generateMipmap': {1: { 0:true }},
+  // compressedTexImage2D and compressedTexSubImage2D are defined below with WebGL 2 entrypoints
+
+  // Buffer objects
+
+  'bindBuffer': {2: { 0:true }},
+  // bufferData and bufferSubData are defined below with WebGL 2 entrypoints
+  'getBufferParameter': {2: { 0:true, 1:true }},
+
+  // Renderbuffers and framebuffers
+
+  'pixelStorei': {2: { 0:true, 1:true }},
+  // readPixels is defined below with WebGL 2 entrypoints
+  'bindRenderbuffer': {2: { 0:true }},
+  'bindFramebuffer': {2: { 0:true }},
+  'checkFramebufferStatus': {1: { 0:true }},
+  'framebufferRenderbuffer': {4: { 0:true, 1:true, 2:true }},
+  'framebufferTexture2D': {5: { 0:true, 1:true, 2:true }},
+  'getFramebufferAttachmentParameter': {3: { 0:true, 1:true, 2:true }},
+  'getRenderbufferParameter': {2: { 0:true, 1:true }},
+  'renderbufferStorage': {4: { 0:true, 1:true }},
+
+  // Frame buffer operations (clear, blend, depth test, stencil)
+
+  'clear': {1: { 0: { 'enumBitwiseOr': ['COLOR_BUFFER_BIT', 'DEPTH_BUFFER_BIT', 'STENCIL_BUFFER_BIT'] }}},
+  'depthFunc': {1: { 0:true }},
+  'blendFunc': {2: { 0:true, 1:true }},
+  'blendFuncSeparate': {4: { 0:true, 1:true, 2:true, 3:true }},
+  'blendEquation': {1: { 0:true }},
+  'blendEquationSeparate': {2: { 0:true, 1:true }},
+  'stencilFunc': {3: { 0:true }},
+  'stencilFuncSeparate': {4: { 0:true, 1:true }},
+  'stencilMaskSeparate': {2: { 0:true }},
+  'stencilOp': {3: { 0:true, 1:true, 2:true }},
+  'stencilOpSeparate': {4: { 0:true, 1:true, 2:true, 3:true }},
+
+  // Culling
+
+  'cullFace': {1: { 0:true }},
+  'frontFace': {1: { 0:true }},
+
+  // ANGLE_instanced_arrays extension
+
+  'drawArraysInstancedANGLE': {4: { 0:true }},
+  'drawElementsInstancedANGLE': {5: { 0:true, 2:true }},
+
+  // EXT_blend_minmax extension
+
+  'blendEquationEXT': {1: { 0:true }},
+
+  // WebGL 2 Buffer objects
+
+  'bufferData': {
+    3: { 0:true, 2:true }, // WebGL 1
+    4: { 0:true, 2:true }, // WebGL 2
+    5: { 0:true, 2:true }  // WebGL 2
+  },
+  'bufferSubData': {
+    3: { 0:true }, // WebGL 1
+    4: { 0:true }, // WebGL 2
+    5: { 0:true }  // WebGL 2
+  },
+  'copyBufferSubData': {5: { 0:true, 1:true }},
+  'getBufferSubData': {3: { 0:true }, 4: { 0:true }, 5: { 0:true }},
+
+  // WebGL 2 Framebuffer objects
+
+  'blitFramebuffer': {10: { 8: { 'enumBitwiseOr': ['COLOR_BUFFER_BIT', 'DEPTH_BUFFER_BIT', 'STENCIL_BUFFER_BIT'] }, 9:true }},
+  'framebufferTextureLayer': {5: { 0:true, 1:true }},
+  'invalidateFramebuffer': {2: { 0:true }},
+  'invalidateSubFramebuffer': {6: { 0:true }},
+  'readBuffer': {1: { 0:true }},
+
+  // WebGL 2 Renderbuffer objects
+
+  'getInternalformatParameter': {3: { 0:true, 1:true, 2:true }},
+  'renderbufferStorageMultisample': {5: { 0:true, 2:true }},
+
+  // WebGL 2 Texture objects
+
+  'texStorage2D': {5: { 0:true, 2:true }},
+  'texStorage3D': {6: { 0:true, 2:true }},
+  'texImage2D': {
+    9: { 0:true, 2:true, 6:true, 7:true }, // WebGL 1 & 2
+    6: { 0:true, 2:true, 3:true, 4:true }, // WebGL 1
+    10: { 0:true, 2:true, 6:true, 7:true } // WebGL 2
+  },
+  'texImage3D': {
+    10: { 0:true, 2:true, 7:true, 8:true },
+    11: { 0:true, 2:true, 7:true, 8:true }
+  },
+  'texSubImage2D': {
+    9: { 0:true, 6:true, 7:true }, // WebGL 1 & 2
+    7: { 0:true, 4:true, 5:true }, // WebGL 1
+    10: { 0:true, 6:true, 7:true } // WebGL 2
+  },
+  'texSubImage3D': {
+    11: { 0:true, 8:true, 9:true },
+    12: { 0:true, 8:true, 9:true }
+  },
+  'copyTexSubImage3D': {9: { 0:true }},
+  'compressedTexImage2D': {
+    7: { 0: true, 2:true }, // WebGL 1 & 2
+    8: { 0: true, 2:true }, // WebGL 2
+    9: { 0: true, 2:true }  // WebGL 2
+  },
+  'compressedTexImage3D': {
+    8: { 0: true, 2:true },
+    9: { 0: true, 2:true },
+    10: { 0: true, 2:true }
+  },
+  'compressedTexSubImage2D': {
+    8: { 0: true, 6:true }, // WebGL 1 & 2
+    9: { 0: true, 6:true }, // WebGL 2
+    10: { 0: true, 6:true } // WebGL 2
+  },
+  'compressedTexSubImage3D': {
+    10: { 0: true, 8:true },
+    11: { 0: true, 8:true },
+    12: { 0: true, 8:true }
+  },
+
+  // WebGL 2 Vertex attribs
+
+  'vertexAttribIPointer': {5: { 2:true }},
+
+  // WebGL 2 Writing to the drawing buffer
+
+  'drawArraysInstanced': {4: { 0:true }},
+  'drawElementsInstanced': {5: { 0:true, 2:true }},
+  'drawRangeElements': {6: { 0:true, 4:true }},
+
+  // WebGL 2 Reading back pixels
+
+  'readPixels': {
+    7: { 4:true, 5:true }, // WebGL 1 & 2
+    8: { 4:true, 5:true }  // WebGL 2
+  },
+
+  // WebGL 2 Multiple Render Targets
+
+  'clearBufferfv': {3: { 0:true }, 4: { 0:true }},
+  'clearBufferiv': {3: { 0:true }, 4: { 0:true }},
+  'clearBufferuiv': {3: { 0:true }, 4: { 0:true }},
+  'clearBufferfi': {4: { 0:true }},
+
+  // WebGL 2 Query objects
+
+  'beginQuery': {2: { 0:true }},
+  'endQuery': {1: { 0:true }},
+  'getQuery': {2: { 0:true, 1:true }},
+  'getQueryParameter': {2: { 1:true }},
+
+  // WebGL 2 Sampler objects
+
+  'samplerParameteri': {3: { 1:true, 2:true }},
+  'samplerParameterf': {3: { 1:true }},
+  'getSamplerParameter': {2: { 1:true }},
+
+  // WebGL 2 Sync objects
+
+  'fenceSync': {2: { 0:true, 1: { 'enumBitwiseOr': [] } }},
+  'clientWaitSync': {3: { 1: { 'enumBitwiseOr': ['SYNC_FLUSH_COMMANDS_BIT'] } }},
+  'waitSync': {3: { 1: { 'enumBitwiseOr': [] } }},
+  'getSyncParameter': {2: { 1:true }},
+
+  // WebGL 2 Transform Feedback
+
+  'bindTransformFeedback': {2: { 0:true }},
+  'beginTransformFeedback': {1: { 0:true }},
+  'transformFeedbackVaryings': {3: { 2:true }},
+
+  // WebGL2 Uniform Buffer Objects and Transform Feedback Buffers
+
+  'bindBufferBase': {3: { 0:true }},
+  'bindBufferRange': {5: { 0:true }},
+  'getIndexedParameter': {2: { 0:true }},
+  'getActiveUniforms': {3: { 2:true }},
+  'getActiveUniformBlockParameter': {3: { 2:true }}
+};
+
+/**
+ * Map of numbers to names.
+ * @type {Object}
+ */
+var glEnums: any = null;
+
+/**
+ * Map of names to numbers.
+ * @type {Object}
+ */
+var enumStringToValue : any = null;
+
+/**
+ * Initializes this module. Safe to call more than once.
+ * @param {!WebGLRenderingContext} ctx A WebGL context. If
+ *    you have more than one context it doesn't matter which one
+ *    you pass in, it is only used to pull out constants.
+ */
+function init(ctx : any) {
+  if (glEnums == null) {
+    glEnums = { };
+    enumStringToValue = { };
+    for (var propertyName in ctx) {
+      if (typeof ctx[propertyName] == 'number') {
+        glEnums[ctx[propertyName]] = propertyName;
+        enumStringToValue[propertyName] = ctx[propertyName];
+      }
+    }
+  }
+}
+
+/**
+ * Checks the utils have been initialized.
+ */
+function checkInit() {
+  if (glEnums == null) {
+    throw 'WebGLDebugUtils.init(ctx) not called';
+  }
+}
+
+/**
+ * Returns true or false if value matches any WebGL enum
+ * @param {*} value Value to check if it might be an enum.
+ * @return {boolean} True if value matches one of the WebGL defined enums
+ */
+function mightBeEnum(value) {
+  checkInit();
+  return (glEnums[value] !== undefined);
+}
+
+/**
+ * Gets an string version of an WebGL enum.
+ *
+ * Example:
+ *   var str = WebGLDebugUtil.glEnumToString(ctx.getError());
+ *
+ * @param {number} value Value to return an enum for
+ * @return {string} The string version of the enum.
+ */
+function glEnumToString(value) {
+  checkInit();
+  var name = glEnums[value];
+  return (name !== undefined) ? ("gl." + name) :
+      ("/*UNKNOWN WebGL ENUM*/ 0x" + value.toString(16) + "");
+}
+
+/**
+ * Returns the string version of a WebGL argument.
+ * Attempts to convert enum arguments to strings.
+ * @param {string} functionName the name of the WebGL function.
+ * @param {number} numArgs the number of arguments passed to the function.
+ * @param {number} argumentIndx the index of the argument.
+ * @param {*} value The value of the argument.
+ * @return {string} The value as a string.
+ */
+function glFunctionArgToString(functionName, numArgs, argumentIndex, value) {
+  var funcInfo = glValidEnumContexts[functionName];
+  if (funcInfo !== undefined) {
+    var funcInfo = funcInfo[numArgs];
+    if (funcInfo !== undefined) {
+      if (funcInfo[argumentIndex]) {
+        if (typeof funcInfo[argumentIndex] === 'object' &&
+            funcInfo[argumentIndex]['enumBitwiseOr'] !== undefined) {
+          var enums = funcInfo[argumentIndex]['enumBitwiseOr'];
+          var orResult = 0;
+          var orEnums = [];
+          for (var i = 0; i < enums.length; ++i) {
+            var enumValue = enumStringToValue[enums[i]];
+            if ((value & enumValue) !== 0) {
+              orResult |= enumValue;
+              orEnums.push(glEnumToString(enumValue));
+            }
+          }
+          if (orResult === value) {
+            return orEnums.join(' | ');
+          } else {
+            return glEnumToString(value);
+          }
+        } else {
+          return glEnumToString(value);
+        }
+      }
+    }
+  }
+  if (value === null) {
+    return "null";
+  } else if (value === undefined) {
+    return "undefined";
+  } else if (ArrayBuffer.isView(value)) {
+    // Large typed array views are common in WebGL APIs and create
+    // huge strings in logs.
+    return "<" + value.constructor.name + ">";
+  } else {
+    return value.toString();
+  }
+}
+
+/**
+ * Converts the arguments of a WebGL function to a string.
+ * Attempts to convert enum arguments to strings.
+ *
+ * @param {string} functionName the name of the WebGL function.
+ * @param {number} args The arguments.
+ * @return {string} The arguments as a string.
+ */
+function glFunctionArgsToString(functionName, args) {
+  // apparently we can't do args.join(",");
+  var argStr = "";
+  var numArgs = args.length;
+  for (var ii = 0; ii < numArgs; ++ii) {
+    argStr += ((ii == 0) ? '' : ', ') +
+        glFunctionArgToString(functionName, numArgs, ii, args[ii]);
+  }
+  return argStr;
+};
+
+
+function makePropertyWrapper(wrapper, original, propertyName) {
+  //log("wrap prop: " + propertyName);
+  wrapper.__defineGetter__(propertyName, function() {
+    return original[propertyName];
+  });
+  // TODO(gmane): this needs to handle properties that take more than
+  // one value?
+  wrapper.__defineSetter__(propertyName, function(value) {
+    //log("set: " + propertyName);
+    original[propertyName] = value;
+  });
+}
+
+// Makes a function that calls a function on another object.
+function makeFunctionWrapper(original, functionName) {
+  //log("wrap fn: " + functionName);
+  var f = original[functionName];
+  return function() {
+    //log("call: " + functionName);
+    var result = f.apply(original, arguments);
+    return result;
+  };
+}
+
+/**
+ * Given a WebGL context returns a wrapped context that calls
+ * gl.getError after every command and calls a function if the
+ * result is not gl.NO_ERROR.
+ *
+ * @param {!WebGLRenderingContext} ctx The webgl context to
+ *        wrap.
+ * @param {!function(err, funcName, args): void} opt_onErrorFunc
+ *        The function to call when gl.getError returns an
+ *        error. If not specified the default function calls
+ *        console.log with a message.
+ * @param {!function(funcName, args): void} opt_onFunc The
+ *        function to call when each webgl function is called.
+ *        You can use this to log all calls for example.
+ * @param {!WebGLRenderingContext} opt_err_ctx The webgl context
+ *        to call getError on if different than ctx.
+ */
+function makeDebugContext(ctx, opt_onErrorFunc, opt_onFunc, opt_err_ctx) {
+  opt_err_ctx = opt_err_ctx || ctx;
+  init(ctx);
+  opt_onErrorFunc = opt_onErrorFunc || function(err, functionName, args) {
+        // apparently we can't do args.join(",");
+        var argStr = "";
+        var numArgs = args.length;
+        for (var ii = 0; ii < numArgs; ++ii) {
+          argStr += ((ii == 0) ? '' : ', ') +
+              glFunctionArgToString(functionName, numArgs, ii, args[ii]);
+        }
+        error("WebGL error "+ glEnumToString(err) + " in "+ functionName +
+              "(" + argStr + ")");
+      };
+
+  // Holds booleans for each GL error so after we get the error ourselves
+  // we can still return it to the client app.
+  var glErrorShadow = { };
+
+  // Makes a function that calls a WebGL function and then calls getError.
+  function makeErrorWrapper(ctx, functionName) {
+    return function() {
+      if (opt_onFunc) {
+        opt_onFunc(functionName, arguments);
+      }
+      var result = ctx[functionName].apply(ctx, arguments);
+      var err = opt_err_ctx.getError();
+      if (err != 0) {
+        glErrorShadow[err] = true;
+        opt_onErrorFunc(err, functionName, arguments);
+      }
+      return result;
+    };
+  }
+
+  // Make a an object that has a copy of every property of the WebGL context
+  // but wraps all functions.
+  var wrapper = {};
+  for (var propertyName in ctx) {
+    if (typeof ctx[propertyName] == 'function') {
+      if (propertyName != 'getExtension') {
+        wrapper[propertyName] = makeErrorWrapper(ctx, propertyName);
+      } else {
+        var wrapped = makeErrorWrapper(ctx, propertyName);
+        wrapper[propertyName] = function () {
+          var result = wrapped.apply(ctx, arguments);
+          if (!result) {
+            return null;
+          }
+          return makeDebugContext(result, opt_onErrorFunc, opt_onFunc, opt_err_ctx);
+        };
+      }
+    } else {
+      makePropertyWrapper(wrapper, ctx, propertyName);
+    }
+  }
+
+  // Override the getError function with one that returns our saved results.
+  wrapper.getError = function() {
+    for (var err in glErrorShadow) {
+      if (glErrorShadow.hasOwnProperty(err)) {
+        if (glErrorShadow[err]) {
+          glErrorShadow[err] = false;
+          return err;
+        }
+      }
+    }
+    return ctx.NO_ERROR;
+  };
+
+  return wrapper;
+}
+
+function resetToInitialState(ctx) {
+  var isWebGL2RenderingContext = !!ctx.createTransformFeedback;
+
+  if (isWebGL2RenderingContext) {
+    ctx.bindVertexArray(null);
+  }
+
+  var numAttribs = ctx.getParameter(ctx.MAX_VERTEX_ATTRIBS);
+  var tmp = ctx.createBuffer();
+  ctx.bindBuffer(ctx.ARRAY_BUFFER, tmp);
+  for (var ii = 0; ii < numAttribs; ++ii) {
+    ctx.disableVertexAttribArray(ii);
+    ctx.vertexAttribPointer(ii, 4, ctx.FLOAT, false, 0, 0);
+    ctx.vertexAttrib1f(ii, 0);
+    if (isWebGL2RenderingContext) {
+      ctx.vertexAttribDivisor(ii, 0);
+    }
+  }
+  ctx.deleteBuffer(tmp);
+
+  var numTextureUnits = ctx.getParameter(ctx.MAX_TEXTURE_IMAGE_UNITS);
+  for (var ii = 0; ii < numTextureUnits; ++ii) {
+    ctx.activeTexture(ctx.TEXTURE0 + ii);
+    ctx.bindTexture(ctx.TEXTURE_CUBE_MAP, null);
+    ctx.bindTexture(ctx.TEXTURE_2D, null);
+    if (isWebGL2RenderingContext) {
+      ctx.bindTexture(ctx.TEXTURE_2D_ARRAY, null);
+      ctx.bindTexture(ctx.TEXTURE_3D, null);
+      ctx.bindSampler(ii, null);
+    }
+  }
+
+  ctx.activeTexture(ctx.TEXTURE0);
+  ctx.useProgram(null);
+  ctx.bindBuffer(ctx.ARRAY_BUFFER, null);
+  ctx.bindBuffer(ctx.ELEMENT_ARRAY_BUFFER, null);
+  ctx.bindFramebuffer(ctx.FRAMEBUFFER, null);
+  ctx.bindRenderbuffer(ctx.RENDERBUFFER, null);
+  ctx.disable(ctx.BLEND);
+  ctx.disable(ctx.CULL_FACE);
+  ctx.disable(ctx.DEPTH_TEST);
+  ctx.disable(ctx.DITHER);
+  ctx.disable(ctx.SCISSOR_TEST);
+  ctx.blendColor(0, 0, 0, 0);
+  ctx.blendEquation(ctx.FUNC_ADD);
+  ctx.blendFunc(ctx.ONE, ctx.ZERO);
+  ctx.clearColor(0, 0, 0, 0);
+  ctx.clearDepth(1);
+  ctx.clearStencil(-1);
+  ctx.colorMask(true, true, true, true);
+  ctx.cullFace(ctx.BACK);
+  ctx.depthFunc(ctx.LESS);
+  ctx.depthMask(true);
+  ctx.depthRange(0, 1);
+  ctx.frontFace(ctx.CCW);
+  ctx.hint(ctx.GENERATE_MIPMAP_HINT, ctx.DONT_CARE);
+  ctx.lineWidth(1);
+  ctx.pixelStorei(ctx.PACK_ALIGNMENT, 4);
+  ctx.pixelStorei(ctx.UNPACK_ALIGNMENT, 4);
+  ctx.pixelStorei(ctx.UNPACK_FLIP_Y_WEBGL, false);
+  ctx.pixelStorei(ctx.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
+  // TODO: Delete this IF.
+  if (ctx.UNPACK_COLORSPACE_CONVERSION_WEBGL) {
+    ctx.pixelStorei(ctx.UNPACK_COLORSPACE_CONVERSION_WEBGL, ctx.BROWSER_DEFAULT_WEBGL);
+  }
+  ctx.polygonOffset(0, 0);
+  ctx.sampleCoverage(1, false);
+  ctx.scissor(0, 0, ctx.canvas.width, ctx.canvas.height);
+  ctx.stencilFunc(ctx.ALWAYS, 0, 0xFFFFFFFF);
+  ctx.stencilMask(0xFFFFFFFF);
+  ctx.stencilOp(ctx.KEEP, ctx.KEEP, ctx.KEEP);
+  ctx.viewport(0, 0, ctx.canvas.width, ctx.canvas.height);
+  ctx.clear(ctx.COLOR_BUFFER_BIT | ctx.DEPTH_BUFFER_BIT | ctx.STENCIL_BUFFER_BIT);
+
+  if (isWebGL2RenderingContext) {
+    ctx.drawBuffers([ctx.BACK]);
+    ctx.readBuffer(ctx.BACK);
+    ctx.bindBuffer(ctx.COPY_READ_BUFFER, null);
+    ctx.bindBuffer(ctx.COPY_WRITE_BUFFER, null);
+    ctx.bindBuffer(ctx.PIXEL_PACK_BUFFER, null);
+    ctx.bindBuffer(ctx.PIXEL_UNPACK_BUFFER, null);
+    var numTransformFeedbacks = ctx.getParameter(ctx.MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS);
+    for (var ii = 0; ii < numTransformFeedbacks; ++ii) {
+      ctx.bindBufferBase(ctx.TRANSFORM_FEEDBACK_BUFFER, ii, null);
+    }
+    var numUBOs = ctx.getParameter(ctx.MAX_UNIFORM_BUFFER_BINDINGS);
+    for (var ii = 0; ii < numUBOs; ++ii) {
+      ctx.bindBufferBase(ctx.UNIFORM_BUFFER, ii, null);
+    }
+    ctx.disable(ctx.RASTERIZER_DISCARD);
+    ctx.pixelStorei(ctx.UNPACK_IMAGE_HEIGHT, 0);
+    ctx.pixelStorei(ctx.UNPACK_SKIP_IMAGES, 0);
+    ctx.pixelStorei(ctx.UNPACK_ROW_LENGTH, 0);
+    ctx.pixelStorei(ctx.UNPACK_SKIP_ROWS, 0);
+    ctx.pixelStorei(ctx.UNPACK_SKIP_PIXELS, 0);
+    ctx.pixelStorei(ctx.PACK_ROW_LENGTH, 0);
+    ctx.pixelStorei(ctx.PACK_SKIP_ROWS, 0);
+    ctx.pixelStorei(ctx.PACK_SKIP_PIXELS, 0);
+    ctx.hint(ctx.FRAGMENT_SHADER_DERIVATIVE_HINT, ctx.DONT_CARE);
+  }
+
+  // TODO: This should NOT be needed but Firefox fails with 'hint'
+  while(ctx.getError());
+}
+
+function makeLostContextSimulatingCanvas(canvas) {
+  var unwrappedContext_;
+  var wrappedContext_;
+  var onLost_ = [];
+  var onRestored_ = [];
+  var wrappedContext_ = {};
+  var contextId_ = 1;
+  var contextLost_ = false;
+  var resourceId_ = 0;
+  var resourceDb_ = [];
+  var numCallsToLoseContext_ = 0;
+  var numCalls_ = 0;
+  var canRestore_ = false;
+  var restoreTimeout_ = 0;
+  var isWebGL2RenderingContext;
+
+  // Holds booleans for each GL error so can simulate errors.
+  var glErrorShadow_ = { };
+
+  canvas.getContext = function(f) {
+    return function() {
+      var ctx = f.apply(canvas, arguments);
+      // Did we get a context and is it a WebGL context?
+      if ((ctx instanceof WebGLRenderingContext) || (window.WebGL2RenderingContext && (ctx instanceof WebGL2RenderingContext))) {
+        if (ctx != unwrappedContext_) {
+          if (unwrappedContext_) {
+            throw "got different context"
+          }
+          isWebGL2RenderingContext = window.WebGL2RenderingContext && (ctx instanceof WebGL2RenderingContext);
+          unwrappedContext_ = ctx;
+          wrappedContext_ = makeLostContextSimulatingContext(unwrappedContext_);
+        }
+        return wrappedContext_;
+      }
+      return ctx;
+    }
+  }(canvas.getContext);
+
+  function wrapEvent(listener) {
+    if (typeof(listener) == "function") {
+      return listener;
+    } else {
+      return function(info) {
+        listener.handleEvent(info);
+      }
+    }
+  }
+
+  var addOnContextLostListener = function(listener) {
+    onLost_.push(wrapEvent(listener));
+  };
+
+  var addOnContextRestoredListener = function(listener) {
+    onRestored_.push(wrapEvent(listener));
+  };
+
+
+  function wrapAddEventListener(canvas) {
+    var f = canvas.addEventListener;
+    canvas.addEventListener = function(type, listener, bubble) {
+      switch (type) {
+        case 'webglcontextlost':
+          addOnContextLostListener(listener);
+          break;
+        case 'webglcontextrestored':
+          addOnContextRestoredListener(listener);
+          break;
+        default:
+          f.apply(canvas, arguments);
+      }
+    };
+  }
+
+  wrapAddEventListener(canvas);
+
+  canvas.loseContext = function() {
+    if (!contextLost_) {
+      contextLost_ = true;
+      numCallsToLoseContext_ = 0;
+      ++contextId_;
+      while (unwrappedContext_.getError());
+      clearErrors();
+      glErrorShadow_[unwrappedContext_.CONTEXT_LOST_WEBGL] = true;
+      var event = makeWebGLContextEvent("context lost");
+      var callbacks = onLost_.slice();
+      setTimeout(function() {
+          //log("numCallbacks:" + callbacks.length);
+          for (var ii = 0; ii < callbacks.length; ++ii) {
+            //log("calling callback:" + ii);
+            callbacks[ii](event);
+          }
+          if (restoreTimeout_ >= 0) {
+            setTimeout(function() {
+                canvas.restoreContext();
+              }, restoreTimeout_);
+          }
+        }, 0);
+    }
+  };
+
+  canvas.restoreContext = function() {
+    if (contextLost_) {
+      if (onRestored_.length) {
+        setTimeout(function() {
+            if (!canRestore_) {
+              throw "can not restore. webglcontestlost listener did not call event.preventDefault";
+            }
+            freeResources();
+            resetToInitialState(unwrappedContext_);
+            contextLost_ = false;
+            numCalls_ = 0;
+            canRestore_ = false;
+            var callbacks = onRestored_.slice();
+            var event = makeWebGLContextEvent("context restored");
+            for (var ii = 0; ii < callbacks.length; ++ii) {
+              callbacks[ii](event);
+            }
+          }, 0);
+      }
+    }
+  };
+
+  canvas.loseContextInNCalls = function(numCalls) {
+    if (contextLost_) {
+      throw "You can not ask a lost contet to be lost";
+    }
+    numCallsToLoseContext_ = numCalls_ + numCalls;
+  };
+
+  canvas.getNumCalls = function() {
+    return numCalls_;
+  };
+
+  canvas.setRestoreTimeout = function(timeout) {
+    restoreTimeout_ = timeout;
+  };
+
+  function isWebGLObject(obj) {
+    //return false;
+    return (obj instanceof WebGLBuffer ||
+            obj instanceof WebGLFramebuffer ||
+            obj instanceof WebGLProgram ||
+            obj instanceof WebGLRenderbuffer ||
+            obj instanceof WebGLShader ||
+            obj instanceof WebGLTexture);
+  }
+
+  function checkResources(args) {
+    for (var ii = 0; ii < args.length; ++ii) {
+      var arg = args[ii];
+      if (isWebGLObject(arg)) {
+        return arg.__webglDebugContextLostId__ == contextId_;
+      }
+    }
+    return true;
+  }
+
+  function clearErrors() {
+    var k = Object.keys(glErrorShadow_);
+    for (var ii = 0; ii < k.length; ++ii) {
+      delete glErrorShadow_[k[ii]];
+    }
+  }
+
+  function loseContextIfTime() {
+    ++numCalls_;
+    if (!contextLost_) {
+      if (numCallsToLoseContext_ == numCalls_) {
+        canvas.loseContext();
+      }
+    }
+  }
+
+  // Makes a function that simulates WebGL when out of context.
+  function makeLostContextFunctionWrapper(ctx, functionName) {
+    var f = ctx[functionName];
+    return function() {
+      // log("calling:" + functionName);
+      // Only call the functions if the context is not lost.
+      loseContextIfTime();
+      if (!contextLost_) {
+        //if (!checkResources(arguments)) {
+        //  glErrorShadow_[wrappedContext_.INVALID_OPERATION] = true;
+        //  return;
+        //}
+        var result = f.apply(ctx, arguments);
+        return result;
+      }
+    };
+  }
+
+  function freeResources() {
+    for (var ii = 0; ii < resourceDb_.length; ++ii) {
+      var resource = resourceDb_[ii];
+      if (resource instanceof WebGLBuffer) {
+        unwrappedContext_.deleteBuffer(resource);
+      } else if (resource instanceof WebGLFramebuffer) {
+        unwrappedContext_.deleteFramebuffer(resource);
+      } else if (resource instanceof WebGLProgram) {
+        unwrappedContext_.deleteProgram(resource);
+      } else if (resource instanceof WebGLRenderbuffer) {
+        unwrappedContext_.deleteRenderbuffer(resource);
+      } else if (resource instanceof WebGLShader) {
+        unwrappedContext_.deleteShader(resource);
+      } else if (resource instanceof WebGLTexture) {
+        unwrappedContext_.deleteTexture(resource);
+      }
+      else if (isWebGL2RenderingContext) {
+        if (resource instanceof WebGLQuery) {
+          unwrappedContext_.deleteQuery(resource);
+        } else if (resource instanceof WebGLSampler) {
+          unwrappedContext_.deleteSampler(resource);
+        } else if (resource instanceof WebGLSync) {
+          unwrappedContext_.deleteSync(resource);
+        } else if (resource instanceof WebGLTransformFeedback) {
+          unwrappedContext_.deleteTransformFeedback(resource);
+        } else if (resource instanceof WebGLVertexArrayObject) {
+          unwrappedContext_.deleteVertexArray(resource);
+        }
+      }
+    }
+  }
+
+  function makeWebGLContextEvent(statusMessage) {
+    return {
+      statusMessage: statusMessage,
+      preventDefault: function() {
+          canRestore_ = true;
+        }
+    };
+  }
+
+  return canvas;
+
+  function makeLostContextSimulatingContext(ctx) {
+    // copy all functions and properties to wrapper
+    for (var propertyName in ctx) {
+      if (typeof ctx[propertyName] == 'function') {
+         wrappedContext_[propertyName] = makeLostContextFunctionWrapper(
+             ctx, propertyName);
+       } else {
+         makePropertyWrapper(wrappedContext_, ctx, propertyName);
+       }
+    }
+
+    // Wrap a few functions specially.
+    wrappedContext_.getError = function() {
+      loseContextIfTime();
+      if (!contextLost_) {
+        var err;
+        while (err = unwrappedContext_.getError()) {
+          glErrorShadow_[err] = true;
+        }
+      }
+      for (var err in glErrorShadow_) {
+        if (glErrorShadow_[err]) {
+          delete glErrorShadow_[err];
+          return err;
+        }
+      }
+      return wrappedContext_.NO_ERROR;
+    };
+
+    var creationFunctions = [
+      "createBuffer",
+      "createFramebuffer",
+      "createProgram",
+      "createRenderbuffer",
+      "createShader",
+      "createTexture"
+    ];
+    if (isWebGL2RenderingContext) {
+      creationFunctions.push(
+        "createQuery",
+        "createSampler",
+        "fenceSync",
+        "createTransformFeedback",
+        "createVertexArray"
+      );
+    }
+    for (var ii = 0; ii < creationFunctions.length; ++ii) {
+      var functionName = creationFunctions[ii];
+      wrappedContext_[functionName] = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return null;
+          }
+          var obj = f.apply(ctx, arguments);
+          obj.__webglDebugContextLostId__ = contextId_;
+          resourceDb_.push(obj);
+          return obj;
+        };
+      }(ctx[functionName]);
+    }
+
+    var functionsThatShouldReturnNull = [
+      "getActiveAttrib",
+      "getActiveUniform",
+      "getBufferParameter",
+      "getContextAttributes",
+      "getAttachedShaders",
+      "getFramebufferAttachmentParameter",
+      "getParameter",
+      "getProgramParameter",
+      "getProgramInfoLog",
+      "getRenderbufferParameter",
+      "getShaderParameter",
+      "getShaderInfoLog",
+      "getShaderSource",
+      "getTexParameter",
+      "getUniform",
+      "getUniformLocation",
+      "getVertexAttrib"
+    ];
+    if (isWebGL2RenderingContext) {
+      functionsThatShouldReturnNull.push(
+        "getInternalformatParameter",
+        "getQuery",
+        "getQueryParameter",
+        "getSamplerParameter",
+        "getSyncParameter",
+        "getTransformFeedbackVarying",
+        "getIndexedParameter",
+        "getUniformIndices",
+        "getActiveUniforms",
+        "getActiveUniformBlockParameter",
+        "getActiveUniformBlockName"
+      );
+    }
+    for (var ii = 0; ii < functionsThatShouldReturnNull.length; ++ii) {
+      var functionName = functionsThatShouldReturnNull[ii];
+      wrappedContext_[functionName] = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return null;
+          }
+          return f.apply(ctx, arguments);
+        }
+      }(wrappedContext_[functionName]);
+    }
+
+    var isFunctions = [
+      "isBuffer",
+      "isEnabled",
+      "isFramebuffer",
+      "isProgram",
+      "isRenderbuffer",
+      "isShader",
+      "isTexture"
+    ];
+    if (isWebGL2RenderingContext) {
+      isFunctions.push(
+        "isQuery",
+        "isSampler",
+        "isSync",
+        "isTransformFeedback",
+        "isVertexArray"
+      );
+    }
+    for (var ii = 0; ii < isFunctions.length; ++ii) {
+      var functionName = isFunctions[ii];
+      wrappedContext_[functionName] = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return false;
+          }
+          return f.apply(ctx, arguments);
+        }
+      }(wrappedContext_[functionName]);
+    }
+
+    wrappedContext_.checkFramebufferStatus = function(f) {
+      return function() {
+        loseContextIfTime();
+        if (contextLost_) {
+          return wrappedContext_.FRAMEBUFFER_UNSUPPORTED;
+        }
+        return f.apply(ctx, arguments);
+      };
+    }(wrappedContext_.checkFramebufferStatus);
+
+    wrappedContext_.getAttribLocation = function(f) {
+      return function() {
+        loseContextIfTime();
+        if (contextLost_) {
+          return -1;
+        }
+        return f.apply(ctx, arguments);
+      };
+    }(wrappedContext_.getAttribLocation);
+
+    wrappedContext_.getVertexAttribOffset = function(f) {
+      return function() {
+        loseContextIfTime();
+        if (contextLost_) {
+          return 0;
+        }
+        return f.apply(ctx, arguments);
+      };
+    }(wrappedContext_.getVertexAttribOffset);
+
+    wrappedContext_.isContextLost = function() {
+      return contextLost_;
+    };
+
+    if (isWebGL2RenderingContext) {
+      wrappedContext_.getFragDataLocation = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return -1;
+          }
+          return f.apply(ctx, arguments);
+        };
+      }(wrappedContext_.getFragDataLocation);
+
+      wrappedContext_.clientWaitSync = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return wrappedContext_.WAIT_FAILED;
+          }
+          return f.apply(ctx, arguments);
+        };
+      }(wrappedContext_.clientWaitSync);
+
+      wrappedContext_.getUniformBlockIndex = function(f) {
+        return function() {
+          loseContextIfTime();
+          if (contextLost_) {
+            return wrappedContext_.INVALID_INDEX;
+          }
+          return f.apply(ctx, arguments);
+        };
+      }(wrappedContext_.getUniformBlockIndex);
+    }
+
+    return wrappedContext_;
+  }
+}
+
+return {
+  /**
+   * Initializes this module. Safe to call more than once.
+   * @param {!WebGLRenderingContext} ctx A WebGL context. If
+   *    you have more than one context it doesn't matter which one
+   *    you pass in, it is only used to pull out constants.
+   */
+  'init': init,
+
+  /**
+   * Returns true or false if value matches any WebGL enum
+   * @param {*} value Value to check if it might be an enum.
+   * @return {boolean} True if value matches one of the WebGL defined enums
+   */
+  'mightBeEnum': mightBeEnum,
+
+  /**
+   * Gets an string version of an WebGL enum.
+   *
+   * Example:
+   *   WebGLDebugUtil.init(ctx);
+   *   var str = WebGLDebugUtil.glEnumToString(ctx.getError());
+   *
+   * @param {number} value Value to return an enum for
+   * @return {string} The string version of the enum.
+   */
+  'glEnumToString': glEnumToString,
+
+  /**
+   * Converts the argument of a WebGL function to a string.
+   * Attempts to convert enum arguments to strings.
+   *
+   * Example:
+   *   WebGLDebugUtil.init(ctx);
+   *   var str = WebGLDebugUtil.glFunctionArgToString('bindTexture', 2, 0, gl.TEXTURE_2D);
+   *
+   * would return 'TEXTURE_2D'
+   *
+   * @param {string} functionName the name of the WebGL function.
+   * @param {number} numArgs The number of arguments
+   * @param {number} argumentIndx the index of the argument.
+   * @param {*} value The value of the argument.
+   * @return {string} The value as a string.
+   */
+  'glFunctionArgToString': glFunctionArgToString,
+
+  /**
+   * Converts the arguments of a WebGL function to a string.
+   * Attempts to convert enum arguments to strings.
+   *
+   * @param {string} functionName the name of the WebGL function.
+   * @param {number} args The arguments.
+   * @return {string} The arguments as a string.
+   */
+  'glFunctionArgsToString': glFunctionArgsToString,
+
+  /**
+   * Given a WebGL context returns a wrapped context that calls
+   * gl.getError after every command and calls a function if the
+   * result is not NO_ERROR.
+   *
+   * You can supply your own function if you want. For example, if you'd like
+   * an exception thrown on any GL error you could do this
+   *
+   *    function throwOnGLError(err, funcName, args) {
+   *      throw WebGLDebugUtils.glEnumToString(err) +
+   *            " was caused by call to " + funcName;
+   *    };
+   *
+   *    ctx = WebGLDebugUtils.makeDebugContext(
+   *        canvas.getContext("webgl"), throwOnGLError);
+   *
+   * @param {!WebGLRenderingContext} ctx The webgl context to wrap.
+   * @param {!function(err, funcName, args): void} opt_onErrorFunc The function
+   *     to call when gl.getError returns an error. If not specified the default
+   *     function calls console.log with a message.
+   * @param {!function(funcName, args): void} opt_onFunc The
+   *     function to call when each webgl function is called. You
+   *     can use this to log all calls for example.
+   */
+  'makeDebugContext': makeDebugContext,
+
+  /**
+   * Given a canvas element returns a wrapped canvas element that will
+   * simulate lost context. The canvas returned adds the following functions.
+   *
+   * loseContext:
+   *   simulates a lost context event.
+   *
+   * restoreContext:
+   *   simulates the context being restored.
+   *
+   * lostContextInNCalls:
+   *   loses the context after N gl calls.
+   *
+   * getNumCalls:
+   *   tells you how many gl calls there have been so far.
+   *
+   * setRestoreTimeout:
+   *   sets the number of milliseconds until the context is restored
+   *   after it has been lost. Defaults to 0. Pass -1 to prevent
+   *   automatic restoring.
+   *
+   * @param {!Canvas} canvas The canvas element to wrap.
+   */
+  'makeLostContextSimulatingCanvas': makeLostContextSimulatingCanvas,
+
+  /**
+   * Resets a context to the initial state.
+   * @param {!WebGLRenderingContext} ctx The webgl context to
+   *     reset.
+   */
+  'resetToInitialState': resetToInitialState
+};
+
+}();
+

+ 475 - 0
src/filler/AnimatableMask.ts

@@ -0,0 +1,475 @@
+import { coverRadius, fillRectangle } from "../base/2d";
+import { Animator } from "../base/Animator";
+import { m4 } from "../base/m4";
+import { Disposable, Scene } from "../base/Scene";
+import { createProgram, createShader } from "../base/utils";
+import { Area, Color } from "./common";
+import { FillerData } from "./FillerData"
+
+
+
+
+export class AnimatingArea {
+  constructor(
+    readonly area: Area,
+    readonly x: number,
+    readonly y: number,
+    public progress: number,
+  ) { }
+}
+
+
+
+
+
+
+export class AnimatableMask implements Disposable {
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    
+    attribute vec4 a_areaId;
+    attribute vec2 a_center;
+    attribute float a_progress;
+    attribute float a_maxRadius;
+    
+    uniform mat4 u_matrix;
+    
+    varying vec4 v_areaId;
+    varying vec2 v_center;
+    varying float v_progress;
+    varying float v_maxRadius;
+    varying vec2 v_texCoord;
+    varying vec2 v_position;
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_areaId = a_areaId;
+      v_center = a_center;
+      v_progress = a_progress;
+      v_maxRadius = a_maxRadius;
+      v_texCoord = a_texCoord;
+      v_position = a_position;
+      
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_map;
+    //uniform sampler2D u_colored;
+    //uniform sampler2D u_mask;
+    varying vec4 v_areaId;
+    varying vec2 v_center;
+    varying float v_progress;
+    varying float v_maxRadius;
+    varying vec2 v_texCoord;
+    varying vec2 v_position;
+    
+    void main() {
+        vec4 map = texture2D(u_map, v_texCoord);
+        float dist = distance(map, v_areaId);
+
+
+        /*
+        if(dist < 0.001) {
+
+          if(v_progress >= 1.0 ) {
+            gl_FragColor = vec4(1, 1, 0, 1);
+          }else{
+            float dist2 = distance(v_position, v_center);
+
+            if(dist2 < v_maxRadius * v_progress) {
+              gl_FragColor = vec4(1, 1, 0, 1);
+            }else{
+              gl_FragColor = vec4(0, 0, 0, 0);
+            }
+          }
+
+        }else{
+          gl_FragColor = vec4(0, 0, 0, 0);
+        }
+          */
+
+
+        //gl_FragColor = vec4(1, 1, 0, 1);
+        //gl_FragColor = map;
+
+        vec4 colored = vec4(1, 1, 0, 1);
+        
+        if(dist < 0.001) {
+            if( v_progress < 1.0 ) {
+                float dist2 = distance(v_position, v_center);
+                float r = v_maxRadius * v_progress + 0.001;
+                if(dist2 < r) {
+                    float f = dist2 / r;
+                    if(f  < v_progress) {
+                        gl_FragColor = colored;
+                    }else if(v_progress < 1.0){
+                        float a = (f - 1.0) / (v_progress  - 1.0);
+                        gl_FragColor = vec4(colored.xyz, a);
+                    }else{
+                        gl_FragColor = colored;
+                    }
+                }else {
+                    gl_FragColor = vec4(0,0,0,0);
+                }
+            }else{
+                gl_FragColor = colored;
+            }
+        }else{
+            gl_FragColor = vec4(0, 0, 0, 0);
+        }
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  aAreaIdLoc: number
+  aCenterLoc: number
+  aProgressLoc: number
+  aMaxRadiusLoc: number
+  uMatrixLoc: WebGLUniformLocation
+
+  positionBuffer: WebGLBuffer
+  texCoordBuffer: WebGLBuffer
+  areaIdBuffer: WebGLBuffer
+  centerBuffer: WebGLBuffer
+  progressBuffer: WebGLBuffer
+  maxRadiusBuffer: WebGLBuffer
+
+
+  dispose(): void {
+    let gl = this.scene.gl
+
+    gl.deleteBuffer(this.positionBuffer)
+    gl.deleteBuffer(this.texCoordBuffer)
+    gl.deleteBuffer(this.areaIdBuffer)
+    gl.deleteBuffer(this.centerBuffer)
+    gl.deleteBuffer(this.progressBuffer)
+    gl.deleteBuffer(this.maxRadiusBuffer)
+    gl.deleteProgram(this.program)
+
+  }
+
+
+
+  positionArray: Float32Array
+  texCoordArray: Float32Array
+  areaIdArray: Float32Array
+  centerArray: Float32Array
+  progressArray: Float32Array
+  maxRadiusArray: Float32Array
+
+
+  matrix: m4.Matrix4
+
+
+  texture: WebGLTexture
+  fb: WebGLFramebuffer
+
+  private animatingAreas: Array<AnimatingArea> = []
+
+  get width(): number { return this.fillerData.width }
+  get height(): number { return this.fillerData.height }
+
+
+  maxCount: number
+
+  constructor(
+    private scene: Scene,
+    private fillerData: FillerData,
+
+  ) {
+
+    const gl = scene.gl
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.aAreaIdLoc = gl.getAttribLocation(this.program, "a_areaId")
+    this.aCenterLoc = gl.getAttribLocation(this.program, "a_center")
+    this.aProgressLoc = gl.getAttribLocation(this.program, "a_progress")
+    this.aMaxRadiusLoc = gl.getAttribLocation(this.program, "a_maxRadius")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+
+
+
+    this.maxCount = fillerData.data.maxAreaCountOfGroup
+
+
+    this.positionBuffer = gl.createBuffer()!
+    this.texCoordBuffer = gl.createBuffer()!
+    this.areaIdBuffer = gl.createBuffer()!
+    this.centerBuffer = gl.createBuffer()!
+    this.progressBuffer = gl.createBuffer()!
+    this.maxRadiusBuffer = gl.createBuffer()!
+
+
+
+
+
+    this.matrix = m4.projectionNoflipY(fillerData.width, fillerData.height)
+
+
+    this.texture = gl.createTexture()!
+    gl.bindTexture(gl.TEXTURE_2D, this.texture)
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.fillerData.width, this.fillerData.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
+    //gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, this.fillerData.width, this.fillerData.height, 0, gl.RGB, gl.UNSIGNED_SHORT_5_6_5, null)
+
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+
+    this.fb = gl.createFramebuffer()!
+    gl.bindFramebuffer(gl.FRAMEBUFFER, this.fb)
+    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture, 0)
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+
+
+    this.positionArray = new Float32Array(this.maxCount * 12)
+    this.texCoordArray = new Float32Array(this.maxCount * 12)
+    this.areaIdArray = new Float32Array(this.maxCount * 24)
+    this.centerArray = new Float32Array(this.maxCount * 12)
+    this.progressArray = new Float32Array(this.maxCount * 6)
+    this.maxRadiusArray = new Float32Array(this.maxCount * 6)
+
+  }
+
+
+
+
+
+  addArea(area: Area, cx: number, cy: number, duration = 800) {
+    let aa = new AnimatingArea(area, cx, cy, 0)
+    let animator = new Animator(duration, () => {
+      aa.progress = animator.value()
+    }, () => {
+
+    })
+    this.scene.addAnimator(animator)
+    this.animatingAreas.push(aa);
+  }
+
+
+
+
+  fillPoint(arr: Float32Array, offset: number, x: number, y: number, count: number) {
+    for (var i = 0; i < count; i++) {
+      arr[offset + i * 2] = x
+      arr[offset + i * 2 + 1] = y
+    }
+  }
+
+
+  fillNumber(arr: Float32Array, offset: number, n: number, count: number) {
+    for (var i = 0; i < count; i++) {
+      arr[offset + i] = n
+    }
+  }
+
+
+  flush() {
+    // console.log('animationAreas.length=', this.animatingAreas.length)
+
+    if (this.animatingAreas.length <= 0) return
+
+    var vertexOffset = 0
+    var colorOffset = 0
+    var nOffset = 0
+
+    const width = this.fillerData.width
+    const height = this.fillerData.height
+
+    for (var i = 0; i < this.animatingAreas.length; i++) {
+
+      var aa = this.animatingAreas[i]
+      var area = aa.area
+
+      fillRectangle(this.positionArray, vertexOffset, area.rect.x, area.rect.y, area.rect.width, area.rect.height)
+      fillRectangle(this.texCoordArray, vertexOffset, area.rect.x / width, area.rect.y / height, area.rect.width / width, area.rect.height / height)
+      this.fillPoint(this.centerArray, vertexOffset, aa.x, aa.y, 6)
+
+      var color = new Color(area.id)
+      color.fillFloatArray(this.areaIdArray, colorOffset, 6)
+
+      var maxRadius = coverRadius(area.rect, aa.x, aa.y)
+      // console.log('rect=', area.rect, maxRadius, aa.x, aa.y);
+      //maxRadius = 
+
+      this.fillNumber(this.progressArray, nOffset, aa.progress, 6)
+      this.fillNumber(this.maxRadiusArray, nOffset, maxRadius, 6)
+
+      vertexOffset += 12
+      colorOffset += 24
+      nOffset += 6
+    }
+
+    //console.log('areaIdArray', this.areaIdArray)
+
+
+    const gl = this.scene.gl
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
+    gl.bufferData(gl.ARRAY_BUFFER, this.positionArray, gl.STATIC_DRAW)
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer)
+    gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW)
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.areaIdBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.areaIdArray, gl.STATIC_DRAW)
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.centerBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.centerArray, gl.STATIC_DRAW)
+
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.progressBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.progressArray, gl.STATIC_DRAW)
+
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.maxRadiusBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.maxRadiusArray, gl.STATIC_DRAW)
+
+
+    this.draw(this.animatingAreas.length * 6)
+
+    this.animatingAreas = this.animatingAreas.filter(aa => aa.progress < 1)
+
+  }
+
+
+  private draw(n: number) {
+    const gl = this.scene.gl;
+
+    gl.bindFramebuffer(gl.FRAMEBUFFER, this.fb)
+
+    //console.log('fb=', this.progressArray)
+
+    gl.useProgram(this.program);
+
+    gl.viewport(0, 0, this.fillerData.width, this.fillerData.height)
+
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aAreaIdLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.areaIdBuffer);
+    gl.vertexAttribPointer(this.aAreaIdLoc, 4, gl.FLOAT, false, 0, 0);
+
+
+    gl.enableVertexAttribArray(this.aCenterLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.centerBuffer);
+    gl.vertexAttribPointer(this.aCenterLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aProgressLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.progressBuffer);
+    gl.vertexAttribPointer(this.aProgressLoc, 1, gl.FLOAT, false, 0, 0);
+
+
+    gl.enableVertexAttribArray(this.aMaxRadiusLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.maxRadiusBuffer);
+    gl.vertexAttribPointer(this.aMaxRadiusLoc, 1, gl.FLOAT, false, 0, 0);
+
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.matrix)
+
+
+    gl.activeTexture(gl.TEXTURE0)
+    gl.bindTexture(gl.TEXTURE_2D, this.fillerData.mapTexure)
+
+    gl.drawArrays(gl.TRIANGLES, 0, n)
+
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+
+
+
+
+
+
+  }
+
+
+  // 恢复上次已填色的部分
+  initTask() {
+    let taskLisk = this.fillerData.taskList;
+
+    const width = this.fillerData.width
+    const height = this.fillerData.height
+
+    for (let task of taskLisk) {
+      let area = this.fillerData.data.areaHash.get(task);
+      if (area) {
+        fillRectangle(this.positionArray, 0, area.rect.x, area.rect.y, area.rect.width, area.rect.height)
+        fillRectangle(this.texCoordArray, 0, area.rect.x / width, area.rect.y / height, area.rect.width / width, area.rect.height / height)
+        this.fillPoint(this.centerArray, 0, area.center.x, area.center.y, 6)
+
+        var color = new Color(area.id)
+        color.fillFloatArray(this.areaIdArray, 0, 6)
+
+        var maxRadius = coverRadius(area.rect, area.center.x, area.center.y)
+        // console.log('rect=', area.rect, maxRadius, area.center.x, area.center.y);
+
+        this.fillNumber(this.progressArray, 0, 1, 6)
+        this.fillNumber(this.maxRadiusArray, 0, maxRadius, 6)
+
+
+        const gl = this.scene.gl
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
+        gl.bufferData(gl.ARRAY_BUFFER, this.positionArray, gl.STATIC_DRAW)
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer)
+        gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW)
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.areaIdBuffer);
+        gl.bufferData(gl.ARRAY_BUFFER, this.areaIdArray, gl.STATIC_DRAW)
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.centerBuffer);
+        gl.bufferData(gl.ARRAY_BUFFER, this.centerArray, gl.STATIC_DRAW)
+
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.progressBuffer);
+        gl.bufferData(gl.ARRAY_BUFFER, this.progressArray, gl.STATIC_DRAW)
+
+
+        gl.bindBuffer(gl.ARRAY_BUFFER, this.maxRadiusBuffer);
+        gl.bufferData(gl.ARRAY_BUFFER, this.maxRadiusArray, gl.STATIC_DRAW)
+
+
+        this.draw(6)
+
+      }
+    }
+
+  }
+
+
+
+
+}
+
+
+
+
+
+
+

+ 41 - 0
src/filler/Audio.ts

@@ -0,0 +1,41 @@
+import soundColorDone from "/assets/sound/section_done.mp3?url";
+import soundAllDone from "/assets/sound/color_done_02.mp3?url";
+import soundHint from "/assets/sound/sound_hint.mp3?url";
+
+export enum AudioType {
+  ColorDone,
+  AllDone,
+  Hint,
+}
+
+export default class AudioPlayer {
+  audioColorDone: HTMLAudioElement;
+  audioAllDone: HTMLAudioElement;
+  audioHint: HTMLAudioElement;
+
+  constructor() {
+    this.audioColorDone = new Audio(soundColorDone);
+    this.audioAllDone = new Audio(soundAllDone);
+    this.audioHint = new Audio(soundHint);
+  }
+
+  /**
+   * 播放提示音
+   */
+  playAudio(id: AudioType) {
+    console.log(`play sound ${id}`);
+    switch (id) {
+      case AudioType.ColorDone:
+        this.audioColorDone.play();
+        break;
+      case AudioType.AllDone:
+        this.audioAllDone.play();
+        break;
+      case AudioType.Hint:
+        this.audioHint.play();
+        break;
+      default:
+        break;
+    }
+  }
+}

+ 259 - 0
src/filler/FillerData.ts

@@ -0,0 +1,259 @@
+import { Disposable } from "../base/Scene";
+import { Area, AreaGroup, AreaGroups, createReadFramebuffer, createTexture, Data, Settings, TexImage } from "./common";
+import { createColored } from "./createColored";
+
+export class FillerResource {
+  constructor(
+    public config: AreaGroups,
+    public page: TexImage,
+    public map: TexImage,
+    public numberImage: TexImage,
+    public taskList?: number[],
+    public special?: TexImage,
+    public bg?: TexImage,
+  ) {
+    this.fillAreaInfo(config)
+  }
+
+  fillAreaInfo(groups: AreaGroups) {
+    for (var i = 0; i < groups.length; i++) {
+      var group = groups[i]
+      var label = `${i + 1}`
+      for (var j = 0; j < group.areas.length; j++) {
+        var area = group.areas[j]
+        area.center.label = label
+        area.center.offset = 0
+        area.center.fontHeight = 0
+        area.colored = false
+      }
+    }
+  }
+
+
+}
+
+
+export class FillerConfig {
+  constructor(
+    public settings: Settings,
+    readonly maxScale: number = 10,
+    readonly visibleFontSize: number = 30,
+    public hintColorDark: number = 0xffdddddd,
+    public hintColorLight: number = 0xffaaaaaa,
+  ) { }
+}
+
+
+export interface FillerDataListener {
+  onGroupChange(group: AreaGroup | null): void
+}
+
+
+export interface FillerCallback {
+  onFillSuccess(): void
+  onFillFailed(): void
+  onSwitchGroup(): void
+  onFinish(): void
+}
+
+
+
+export class FillerData implements Disposable {
+
+
+  readonly width: number
+  readonly height: number
+  readonly mapTexure: WebGLTexture
+
+  readonly colored: WebGLTexture
+  readonly data: Data
+
+  readonly fb: WebGLFramebuffer
+
+  private listeners: Array<FillerDataListener> = []
+  private _currentGroup: AreaGroup | null = null
+  public get currentGroup(): AreaGroup | null { return this._currentGroup }
+  public set currentGroup(group: AreaGroup) {
+    this._currentGroup = group
+    this.listeners.forEach(l => l.onGroupChange(group))
+  }
+
+  // 当前group的下标
+  public get currentGroupIndex(): number {
+    if (!this.currentGroup) return -1;
+    return this.data.areaGroups.findIndex((group) => group == this.currentGroup);
+  }
+
+  // 统计在当前group之前有多少个已经完成的
+  public get doneBeforeCount(): number {
+    let count = 0;
+    for (let i = 0; i < this.currentGroupIndex; i++) {
+      if (this.data.areaGroups[i].isAllColored) count++;
+    }
+    return count;
+  }
+
+  constructor(
+    public config: FillerConfig,
+    resource: FillerResource,
+    private gl: WebGL2RenderingContext,
+    public taskList: number[] = [],
+    public callback: FillerCallback | null = null
+  ) {
+    this.width = resource.map.width
+    this.height = resource.map.height
+    this.mapTexure = createTexture(gl, resource.map, gl.NEAREST)
+    this.data = new Data(resource.config)
+
+    this.colored = resource.special ? createTexture(gl, resource.special, gl.LINEAR) : createColored(gl, this.mapTexure, this.data, this.width, this.height)
+    this.fb = createReadFramebuffer(gl, this.mapTexure)
+
+    for (let task of taskList) {
+      let area = this.data.areaHash.get(task);
+      if (area) {
+        area.colored = true;
+      }
+    }
+    // this.currentGroup = this.data.areaGroups[1]
+    let firstUnfinishedGroup = this.data.areaGroups.find(g => !g.isAllColored);
+    if (firstUnfinishedGroup) this.currentGroup = firstUnfinishedGroup;
+  }
+
+
+
+  addListener(listener: FillerDataListener) {
+    this.listeners.push(listener)
+  }
+
+
+  setCurrentGroup(n: number) {
+    this.currentGroup = this.data.areaGroups[n]
+  }
+
+  setColored(area: Area, color: number, x: number, y: number) {
+    area.colored = true
+    this.taskList.push(area.id)
+  }
+
+  /**
+   * 切换到下一个未完成的group,返回null表示当前已没有未完成的group,即整个填色已完成
+   */
+  switchToNextGroup() {
+    if (!this.currentGroup) return null;
+
+    let n = this.data.areaGroups.findIndex((group) => group == this.currentGroup);
+    // search next unfinished group
+    let found = false;
+    for (let i = 0; i < this.data.areaGroups.length; i++) {
+      let group = this.data.areaGroups[n];
+      if (group.isAllColored) {
+        n = (++n) % this.data.areaGroups.length;
+      } else {
+        found = true;
+        break;
+      }
+    }
+    if (found) {
+      this.currentGroup = this.data.areaGroups[n];
+      return this.currentGroup;
+    }
+    return null;
+  }
+
+
+
+  getArea(x: number, y: number, radius: number, areaHash: Map<number, Area>): Area | undefined {
+
+
+    x = Math.round(x)
+    y = Math.round(y)
+
+    let left = x - radius;
+    let top = y - radius;
+    let width = radius * 2 + 1;
+    let height = radius * 2 + 1;
+    let count = width * height
+    let data = new Uint8Array(count * 4) // TODO memory optimize
+    let cx = radius
+    let cy = radius
+
+
+    let gl = this.gl;
+    let fb = this.fb;
+    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
+    gl.readPixels(left, top, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data)
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+    let u32 = new Uint32Array(data.buffer)
+
+
+    let areaSearchSparse: Map<number, number> = new Map()
+    var px = 0
+    var py = 0
+    var dx = 0
+    var dy = 0
+    var dist = 0.0
+    var weight = 0.0
+    var id = 0
+    var pixels = 0
+    for (var i = 0; i < u32.length; i++) {
+      id = u32[i]
+      // 剪枝,忽略超出边界的像素
+      if (id == 0) continue
+      px = i % width
+      py = Math.floor(i / width)
+      dx = px - cx
+      dy = py - cy
+      dist = Math.sqrt(dx * dx + dy * dy)
+
+      // 超出半径以外
+      if (dist > radius) continue
+
+      pixels++
+
+      // 距离越远,权重越低, 确保中心点具有最高优先级
+      weight = 1 / Math.exp(dist)
+      var w = areaSearchSparse.get(id)
+      if (w == null) {
+        areaSearchSparse.set(id, weight)
+      } else {
+        areaSearchSparse.set(id, weight + w)
+      }
+    }
+
+
+
+    let maxWeight = 0
+    let area: Area | undefined = undefined
+    areaSearchSparse.forEach((weight, id) => {
+      console.log(`id=${id}, count=${weight}`)
+
+      var a = areaHash.get(id)
+      if (a != null && !a.colored && weight > maxWeight) {
+        area = a
+        maxWeight = weight
+      }
+    })
+
+    console.log(`Area hit:`, area)
+
+    return area
+  }
+
+
+
+
+  dispose() {
+    this.gl.deleteFramebuffer(this.fb)
+    this.gl.deleteTexture(this.colored)
+    this.gl.deleteTexture(this.mapTexure)
+    this.listeners = []
+
+  }
+
+
+
+
+
+
+}

+ 58 - 0
src/filler/FillerScene.ts

@@ -0,0 +1,58 @@
+import { m4 } from "../base/m4";
+import { Scene } from "../base/Scene";
+import { coerceIn } from "../base/utils";
+import { Area } from "./common";
+import { FillerData } from "./FillerData";
+
+export class FillerScene extends Scene {
+  fillerData?: FillerData;
+
+  constructor(
+    gl: WebGL2RenderingContext,
+    ratio: number,
+    anmationFrameProvider: AnimationFrameProvider = window,
+    interact: boolean = true,
+  ) {
+    super(gl, ratio, anmationFrameProvider, interact);
+  }
+
+  focusToArea(area: Area) {
+    if (this.fillerData == null) return;
+
+    const size = area.center.radius * 4;
+    const scale = coerceIn(
+      Math.min(this.width / size, this.height / size),
+      1,
+      this.fillerData.config.maxScale,
+    );
+    const focusX = this.width / 2;
+    const focusY = this.height / 2;
+
+    const endMat = m4.identity();
+    endMat[0] = scale;
+    endMat[5] = scale;
+    endMat[12] = -scale * area.center.x + focusX;
+    endMat[13] = -scale * area.center.y + focusY;
+
+    this.matrixAnimationTo(endMat, 600);
+  }
+
+  resetToResult() {
+    this.matrixAnimationTo(this.resultMat, 500);
+  }
+
+  hint() {
+    console.log("hint");
+
+    if (this.fillerData == null) return;
+    if (this.fillerData.currentGroup == null) return;
+
+    let group = this.fillerData.currentGroup;
+    let area = group.firstUncoloredArea;
+
+    if (!area) return;
+
+    console.log("find area:", area);
+    this.focusToArea(area);
+  }
+}

+ 147 - 0
src/filler/FingerHint.ts

@@ -0,0 +1,147 @@
+import { FillerData } from "./FillerData";
+import { FillerScene } from "./FillerScene";
+
+/**
+ * DOM overlay 手指提示层
+ * - 页面初始化后 800ms 自动指向当前颜色组第一个待填区块中心
+ * - 用户每次交互(点击填色/点击失败)后重置 2s 闲置计时器
+ * - 全图填完后停止
+ */
+export class FingerHint {
+  private el: HTMLDivElement;
+  private idleTimer: number | null = null;
+  private stopped = false;
+
+  private static readonly IDLE_MS = 2000;
+  /** 手指图片尺寸(CSS px) */
+  private static readonly SIZE = 72;
+  /**
+   * 指尖在图片内的相对位置(0~1)。
+   * finger.png 通常是食指斜向下指,指尖在图片左上角约 (25%, 12%)。
+   */
+  private static readonly TIP_X = 0.25;
+  private static readonly TIP_Y = 0.12;
+
+  constructor(
+    private scene: FillerScene,
+    private fillerData: FillerData,
+    fingerUrl: string,
+  ) {
+    this.el = this.createEl(fingerUrl);
+    document.body.appendChild(this.el);
+    this.injectStyle();
+  }
+
+  // ─── 公开 API ──────────────────────────────────────────────────
+
+  /** 初始化完成后调用,启动首次提示 */
+  start() {
+    this.scheduleHint(800);
+  }
+
+  /** 每次用户有交互动作(填色成功/失败)时调用 */
+  onUserInteraction() {
+    this.hide();
+    if (!this.stopped) {
+      this.scheduleHint(FingerHint.IDLE_MS);
+    }
+  }
+
+  /** 全图填完时调用 */
+  stop() {
+    this.stopped = true;
+    this.hide();
+    if (this.idleTimer !== null) {
+      clearTimeout(this.idleTimer);
+      this.idleTimer = null;
+    }
+  }
+
+  // ─── 私有实现 ──────────────────────────────────────────────────
+
+  private scheduleHint(delay: number) {
+    if (this.idleTimer !== null) clearTimeout(this.idleTimer);
+    this.idleTimer = window.setTimeout(() => this.showHint(), delay);
+  }
+
+  private showHint() {
+    if (this.stopped) return;
+    const group = this.fillerData.currentGroup;
+    if (!group) return;
+    const area = group.firstUncoloredArea;
+    if (!area) return;
+
+    const [cssX, cssY] = this.contentToScreen(area.center.x, area.center.y);
+    this.show(cssX, cssY);
+  }
+
+  /**
+   * 将内容坐标(图片像素空间)转换为 CSS 页面坐标(视口 px)
+   *
+   * Scene 的 userMat 是列主序矩阵,对简单 scale+translate 变换:
+   *   physX = cx * mat[0] + cy * mat[4] + mat[12]
+   *   physY = cx * mat[1] + cy * mat[5] + mat[13]
+   * 再除以 devicePixelRatio 得到 CSS px。
+   */
+  private contentToScreen(cx: number, cy: number): [number, number] {
+    const mat = this.scene.userMat;
+    const ratio = this.scene.ratio;
+    const physX = cx * mat[0] + cy * mat[4] + mat[12];
+    const physY = cx * mat[1] + cy * mat[5] + mat[13];
+    return [physX / ratio, physY / ratio];
+  }
+
+  private show(cssX: number, cssY: number) {
+    const size = FingerHint.SIZE;
+    // 将指尖对齐到目标坐标
+    this.el.style.left = `${cssX - size * FingerHint.TIP_X}px`;
+    this.el.style.top = `${cssY - size * FingerHint.TIP_Y}px`;
+    this.el.style.display = "block";
+    this.el.classList.add("finger-tapping");
+  }
+
+  private hide() {
+    this.el.style.display = "none";
+    this.el.classList.remove("finger-tapping");
+  }
+
+  private createEl(fingerUrl: string): HTMLDivElement {
+    const size = FingerHint.SIZE;
+    const el = document.createElement("div");
+    el.id = "finger-hint";
+    el.style.cssText = `
+      position: fixed;
+      width: ${size}px;
+      height: ${size}px;
+      pointer-events: none;
+      z-index: 999;
+      display: none;
+      transform-origin: ${FingerHint.TIP_X * 100}% ${FingerHint.TIP_Y * 100}%;
+    `;
+    const img = document.createElement("img");
+    img.src = fingerUrl;
+    img.style.cssText = "width:100%;height:100%;object-fit:contain;";
+    img.draggable = false;
+    el.appendChild(img);
+    return el;
+  }
+
+  private injectStyle() {
+    if (document.getElementById("finger-hint-style")) return;
+    const style = document.createElement("style");
+    style.id = "finger-hint-style";
+    style.textContent = `
+      @keyframes finger-tap {
+        0%   { transform: scale(1)    translateY(0);   opacity: 1;   }
+        35%  { transform: scale(0.88) translateY(10px); opacity: 1;  }
+        55%  { transform: scale(0.88) translateY(10px); opacity: 1;  }
+        80%  { transform: scale(1)    translateY(0);   opacity: 1;   }
+        100% { transform: scale(1)    translateY(0);   opacity: 1;   }
+      }
+      #finger-hint.finger-tapping {
+        animation: finger-tap 1s ease-in-out infinite;
+      }
+    `;
+    document.head.appendChild(style);
+  }
+}

+ 282 - 0
src/filler/HintLayer.ts

@@ -0,0 +1,282 @@
+import { fillRectangle, rectangle, rectangleArray } from "../base/2d"
+import { LayerAB, Scene } from "../base/Scene"
+import { createProgram, createShader } from "../base/utils"
+import { AreaGroup, Center, Color, createTexture, TexImage } from "./common";
+import { FillerData, FillerDataListener } from "./FillerData";
+import { Mask } from "./Mask";
+
+
+
+
+export class HintLayer extends LayerAB implements FillerDataListener {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_hint;
+    uniform sampler2D u_mask;
+    uniform vec2 u_pixelSize;
+    uniform vec4 u_scale;
+    uniform vec4 u_colorDark;
+    uniform vec4 u_colorLight;
+    
+    varying vec2 v_texCoord;
+
+
+    const float w1 = 0.147761;
+    const float w2 = 0.118318; 
+    const float w3 = 0.0947416; 
+    
+    
+    vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+      vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+      vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+      vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+    
+      vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+      vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+      vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+    
+      vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+      vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+      vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+    
+      return 
+        C00 + C01 + C02 +
+        C10 + C11 + C12 +
+        C20 + C21 + C22 ;
+    }
+
+
+    void main() {
+      vec4 hint = texture2D(u_hint, v_texCoord);
+      vec4 mask = GaussianBlur(u_mask, v_texCoord, u_pixelSize);
+      if(mask.r >= 0.4) {
+          //gl_FragColor = hint;
+            if(hint.r > 0.5) {
+                gl_FragColor = u_colorDark;
+                //gl_FragColor = vec4(1, 0, 0, 1);
+            }else{
+                gl_FragColor = u_colorLight;
+                //gl_FragColor = vec4(0, 1, 0, 1);
+            }
+      }else {
+            gl_FragColor = vec4(0,0,0,0);
+      }
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uScaleLoc: WebGLUniformLocation
+  uPixelSizeLoc: WebGLUniformLocation
+  uHintLoc: WebGLUniformLocation
+  uMaskLoc: WebGLUniformLocation
+  uColorDarkLoc: WebGLUniformLocation
+  uColorLightLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+  texcoordBuffer: WebGLBuffer
+
+  vertexArray: Float32Array
+  texCoordArray: Float32Array
+
+
+  mask: Mask
+  hintTexture: WebGLTexture
+
+  colorDarkArray = new Float32Array(4)
+  colorLightArray = new Float32Array(4)
+
+
+  override dispose() {
+    this.mask.dispose()
+    let gl = this.scene.gl
+    gl.deleteProgram(this.program)
+    gl.deleteBuffer(this.vertexBuffer)
+    gl.deleteBuffer(this.texcoordBuffer)
+    gl.deleteTexture(this.hintTexture)
+  }
+
+
+  constructor(
+    public readonly scene: Scene,
+    private fillerData: FillerData,
+  ) {
+    super()
+
+    this.fillerData.addListener(this)
+
+    const gl = scene.gl;
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+
+    this.uScaleLoc = gl.getUniformLocation(this.program, "u_scale")!
+    this.uPixelSizeLoc = gl.getUniformLocation(this.program, "u_pixelSize")!
+    this.uHintLoc = gl.getUniformLocation(this.program, "u_hint")!
+    this.uMaskLoc = gl.getUniformLocation(this.program, "u_mask")!
+    this.uColorDarkLoc = gl.getUniformLocation(this.program, "u_colorDark")!
+    this.uColorLightLoc = gl.getUniformLocation(this.program, "u_colorLight")!
+
+
+    this.vertexArray = rectangleArray(0, 0, fillerData.width, fillerData.height)
+    this.texCoordArray = rectangleArray(0, 0, 1, 1)
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+
+    this.texcoordBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW);
+
+
+    this.mask = new Mask(gl, fillerData)
+    this.hintTexture = this.createHintTexture(gl, fillerData.width, fillerData.height)
+
+    if (this.fillerData.currentGroup) {
+      this.setGroup(this.fillerData.currentGroup)
+    }
+
+  }
+
+
+
+  createHintTexture(gl: WebGL2RenderingContext, width: number, height: number): WebGLTexture {
+
+    let texture = gl.createTexture()!;
+    gl.bindTexture(gl.TEXTURE_2D, texture);
+
+    const size = 10;
+    const nx = Math.floor(width / size)
+    const ny = Math.floor(height / size)
+    const pixels = new Uint8Array(nx * ny);
+    for (var y = 0; y < ny; y++) {
+      let k = y % 2 == 0;
+      for (var x = 0; x < nx; x++) {
+        k = !k;
+        let index = y * nx + x;
+        pixels[index] = k ? 0 : 255;
+      }
+    }
+
+    gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, nx, ny, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, pixels);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+    return texture
+  }
+
+
+  setGroup(group: AreaGroup) {
+    for (var i = 0; i < group.areas.length; i++) {
+      this.mask.addArea(group.areas[i])
+    }
+    this.mask.flush(true)
+    this.scene.invalidate()
+  }
+
+
+  onGroupChange(group: AreaGroup | null): void {
+    console.log('onGroupChange', group);
+
+    if (group != null) this.setGroup(group)
+
+    this.fillerData.callback?.onSwitchGroup();
+  }
+
+
+  // 注释掉,不允许随意点击自动切换group
+  // override tap(cx: number, cy: number, sx: number, sy: number): void {
+  //   let area = this.fillerData.getArea(cx, cy, 40, this.fillerData.data.areaHash)
+  //   if (area != null) {
+  //     let group = this.fillerData.data.groupHash.get(area.id)
+  //     // this.setGroup(group!)
+  //     this.fillerData.currentGroup = group as AreaGroup
+  //   }
+  // }
+
+
+
+
+
+
+  override draw() {
+
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+    gl.uniform1f(this.uScaleLoc, this.scene.userMat[0])
+    gl.uniform2f(this.uPixelSizeLoc, 1. / this.mask.width, 1. / this.mask.height)
+
+
+    new Color(this.fillerData.config.hintColorDark).fillFloatArray(this.colorDarkArray, 0)
+    new Color(this.fillerData.config.hintColorLight).fillFloatArray(this.colorLightArray, 0)
+    gl.uniform4fv(this.uColorDarkLoc, this.colorDarkArray)
+    gl.uniform4fv(this.uColorLightLoc, this.colorLightArray)
+
+    gl.uniform1i(this.uHintLoc, 0);  // texture unit 0
+    gl.uniform1i(this.uMaskLoc, 1);  // texture unit 1
+
+    gl.activeTexture(gl.TEXTURE0)
+    gl.bindTexture(gl.TEXTURE_2D, this.hintTexture)
+    gl.activeTexture(gl.TEXTURE1)
+    gl.bindTexture(gl.TEXTURE_2D, this.mask.texture)
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+  }
+
+
+
+
+
+  toString(): string {
+    return `HintLayer()`
+  }
+
+}
+
+
+
+

+ 86 - 0
src/filler/LineArtLayer.ts

@@ -0,0 +1,86 @@
+import { Scene } from "../base/Scene";
+import { TextureLayer } from "../base/TextureLayer";
+
+
+const LineArtShader = /*glsl*/ `
+
+precision highp float;
+uniform sampler2D u_image;
+uniform float u_scale;
+uniform vec2 u_texSize;
+varying vec2 v_texCoord;
+
+const float w1 = 0.147761;
+const float w2 = 0.118318; 
+const float w3 = 0.0947416; 
+
+
+vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+  vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+  vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+  vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+
+  vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+  vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+  vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+
+  vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+  vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+  vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+
+  return 
+    C00 + C01 + C02 +
+    C10 + C11 + C12 +
+    C20 + C21 + C22 ;
+}
+
+void main() {
+
+  vec4 color = GaussianBlur(u_image, v_texCoord, u_texSize);
+  //vec4 color = texture2D(u_image, v_texCoord);
+  //if(u_scale > 100. ) {
+    if(color.a >= 0.4) {
+        gl_FragColor = vec4(0.15, 0.15, 0.15, 1);
+    }else{
+        gl_FragColor = vec4(0, 0, 0, 0);
+    }
+  //}else{
+  //    gl_FragColor = vec4(.15, 0.15, 0.15, color.a);
+  //}
+
+}
+
+
+`;
+
+
+export class LineArtLayer extends TextureLayer {
+
+  constructor(
+    scene: Scene,
+    image: HTMLImageElement | HTMLCanvasElement,
+    width: number,
+    height: number,
+  ) {
+    const gl = scene.gl;
+
+    // Create a texture.
+    let texture = gl.createTexture()!;
+    gl.bindTexture(gl.TEXTURE_2D, texture);
+
+    // Set the parameters so we can render any size image.
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+
+    //gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
+
+    //Upload the image into the texture.
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.ALPHA, gl.ALPHA, gl.UNSIGNED_BYTE, image);
+    //gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+    super(scene, texture, image.width, image.height, width, height, LineArtShader)
+
+  }
+}

+ 33 - 0
src/filler/Loader.ts

@@ -0,0 +1,33 @@
+import { FillerResource } from "./FillerData";
+import { AreaGroups, TexImage } from "./common";
+import { loadImage } from "../base/utils";
+
+async function loadImageBase64(
+  mime: string,
+  base64: string,
+): Promise<HTMLImageElement> {
+  return new Promise((done, reject) => {
+    let img = new Image();
+    img.src = `data:${mime};base64, ${base64}`;
+    img.onload = () => {
+      done(img);
+    };
+    img.onerror = reject;
+  });
+}
+
+function getKey(str: string): string | undefined {
+  let regex = /[0-9a-z]{24}/i;
+  let res = regex.exec(str);
+  if (res == null) return;
+  return res[0];
+}
+
+function str2ab(str: string): Uint16Array {
+  var buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
+  var bufView = new Uint16Array(buf);
+  for (var i = 0, strLen = str.length; i < strLen; i++) {
+    bufView[i] = str.charCodeAt(i);
+  }
+  return bufView;
+}

+ 53 - 0
src/filler/LoadingController.ts

@@ -0,0 +1,53 @@
+// 类型声明
+type LoadingControllerConfig = {
+  minDisplayTime?: number;
+  fadeDuration?: number;
+};
+
+export class LoadingController {
+  private overlay: HTMLElement;
+  private startTime = 0;
+  private config: Required<LoadingControllerConfig>;
+
+  constructor(config: LoadingControllerConfig = {}) {
+    this.overlay = document.getElementById('loading-overlay')!;
+    this.config = {
+      minDisplayTime: 500,
+      fadeDuration: 300,
+      ...config
+    };
+  }
+
+  // 显示加载状态
+  show(): void {
+    this.startTime = Date.now();
+    this.overlay.classList.add('active');
+  }
+
+  // 隐藏加载状态(带最小显示时间保证)
+  async hide(): Promise<void> {
+    const elapsed = Date.now() - this.startTime;
+    const remainingTime = Math.max(this.config.minDisplayTime - elapsed, 0);
+
+    await new Promise(resolve =>
+      setTimeout(resolve, remainingTime)
+    );
+
+    this.overlay.classList.remove('active');
+
+    // 等待动画完成
+    await new Promise(resolve =>
+      setTimeout(resolve, this.config.fadeDuration)
+    );
+  }
+
+  // 包装异步操作
+  async wrap<T>(promise: Promise<T>): Promise<T> {
+    try {
+      this.show();
+      return await promise;
+    } finally {
+      await this.hide();
+    }
+  }
+}

+ 221 - 0
src/filler/Mask.ts

@@ -0,0 +1,221 @@
+import { fillRectangle } from "../base/2d";
+import { m4 } from "../base/m4";
+import { Disposable } from "../base/Scene";
+import { createProgram, createShader } from "../base/utils";
+import { Area, Color } from "./common";
+import { FillerData } from "./FillerData"
+
+
+
+export class Mask implements Disposable {
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    attribute vec4 a_colorId;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+    varying vec4 v_colorId;
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+      v_colorId = a_colorId;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_map;
+    varying vec2 v_texCoord;
+    varying vec4 v_colorId;
+    void main() {
+      vec4 mapColor = texture2D(u_map, v_texCoord);
+      float dist = distance(mapColor, v_colorId);
+      if(dist < 0.001) {
+        gl_FragColor = vec4(1, 1, 0, 1);
+      }else{
+        gl_FragColor = vec4(0, 0, 0, 0);
+      }
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+  positionBuffer: WebGLBuffer
+  texCoordBuffer: WebGLBuffer
+  colorIdBuffer: WebGLBuffer
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  aColorIdLoc: number
+  uMatrixLoc: WebGLUniformLocation
+
+
+
+  matrix: m4.Matrix4
+
+
+  texture: WebGLTexture
+  fb: WebGLFramebuffer
+
+  private pendingAreas: Array<Area> = []
+
+  get width(): number { return this.fillerData.width }
+  get height(): number { return this.fillerData.height }
+
+  dispose() {
+    this.gl.deleteTexture(this.texture)
+    this.gl.deleteFramebuffer(this.fb)
+    this.gl.deleteBuffer(this.positionBuffer)
+    this.gl.deleteBuffer(this.texCoordBuffer)
+    this.gl.deleteBuffer(this.colorIdBuffer)
+    this.gl.deleteProgram(this.program)
+  }
+
+  constructor(
+    private gl: WebGL2RenderingContext,
+    private fillerData: FillerData,
+
+  ) {
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.aColorIdLoc = gl.getAttribLocation(this.program, "a_colorId")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+
+
+    this.positionBuffer = gl.createBuffer()!
+    this.texCoordBuffer = gl.createBuffer()!
+    this.colorIdBuffer = gl.createBuffer()!
+
+    this.matrix = m4.projectionNoflipY(fillerData.width, fillerData.height)
+
+
+    this.texture = gl.createTexture()!
+    gl.bindTexture(gl.TEXTURE_2D, this.texture)
+    //gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.fillerData.width, this.fillerData.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
+    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, this.fillerData.width, this.fillerData.height, 0, gl.RGB, gl.UNSIGNED_SHORT_5_6_5, null)
+
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+
+    this.fb = gl.createFramebuffer()!
+    gl.bindFramebuffer(gl.FRAMEBUFFER, this.fb)
+    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture, 0)
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+
+  }
+
+
+
+  addArea(area: Area) {
+    this.pendingAreas.push(area)
+  }
+
+  flush(clear: boolean = false) {
+    if (this.pendingAreas.length <= 0) return
+
+
+
+    const vertexArray = new Float32Array(12 * this.pendingAreas.length)
+    const texCoordArray = new Float32Array(12 * this.pendingAreas.length)
+    const colorIdArray = new Float32Array(24 * this.pendingAreas.length)
+
+    var vertexOffset = 0
+    var colorOffset = 0
+
+    const width = this.fillerData.width
+    const height = this.fillerData.height
+
+    for (var i = 0; i < this.pendingAreas.length; i++) {
+      var area = this.pendingAreas[i]
+      fillRectangle(vertexArray, vertexOffset, area.rect.x, area.rect.y, area.rect.width, area.rect.height)
+      fillRectangle(texCoordArray, vertexOffset, area.rect.x / width, area.rect.y / height, area.rect.width / width, area.rect.height / height)
+      var color = new Color(area.id)
+      color.fillFloatArray(colorIdArray, colorOffset, 6)
+      vertexOffset += 12
+      colorOffset += 24
+    }
+
+
+    const gl = this.gl
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
+    gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, texCoordArray, gl.STATIC_DRAW);
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.colorIdBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, colorIdArray, gl.STATIC_DRAW);
+
+
+    this.draw(this.pendingAreas.length * 6, clear)
+
+    //console.log('colorIdArray', colorIdArray)
+    //console.log('vertexArray', vertexArray)
+
+    this.pendingAreas = []
+  }
+
+
+  private draw(n: number, clear: boolean = false) {
+    const gl = this.gl;
+
+    gl.bindFramebuffer(gl.FRAMEBUFFER, this.fb)
+
+    console.log('mask.fb=', this.fb)
+
+    gl.useProgram(this.program);
+
+    gl.viewport(0, 0, this.fillerData.width, this.fillerData.height)
+
+    if (clear) {
+      gl.clearColor(0, 0, 0, 0);
+      gl.clear(gl.COLOR_BUFFER_BIT);
+    }
+
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aColorIdLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.colorIdBuffer);
+    gl.vertexAttribPointer(this.aColorIdLoc, 4, gl.FLOAT, false, 0, 0);
+
+
+
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.matrix)
+
+    gl.bindTexture(gl.TEXTURE_2D, this.fillerData.mapTexure)
+
+    gl.drawArrays(gl.TRIANGLES, 0, n);
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+
+  }
+
+}
+
+
+
+
+
+
+

+ 255 - 0
src/filler/NumberLayer.ts

@@ -0,0 +1,255 @@
+import { fillRectangle } from "../base/2d"
+import { LayerAB, Scene } from "../base/Scene"
+import { createProgram, createShader } from "../base/utils"
+import { Center, createTexture, TexImage } from "./common";
+import { FillerData } from "./FillerData";
+
+
+
+
+export class NumberLayer extends LayerAB {
+
+
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_image;
+    varying vec2 v_texCoord;
+
+    void main() {
+      vec4 color = texture2D(u_image, v_texCoord);
+      gl_FragColor = color;
+    }
+  
+  `
+
+
+
+  program: WebGLProgram
+
+
+  aPositionLoc: number
+  aTexcoordLoc: number
+  uMatrixLoc: WebGLUniformLocation
+  uScaleLoc: WebGLUniformLocation
+  uTexSizeLoc: WebGLUniformLocation
+
+  vertexBuffer: WebGLBuffer
+  texcoordBuffer: WebGLBuffer
+
+  vertexArray: Float32Array
+  texCoordArray: Float32Array
+
+  texture: WebGLTexture
+
+  digits: number
+  centers: Array<Center>
+
+  dispose() {
+    let gl = this.scene.gl
+    gl.deleteProgram(this.program)
+    gl.deleteBuffer(this.vertexBuffer)
+    gl.deleteBuffer(this.texcoordBuffer)
+    gl.deleteTexture(this.texture)
+  }
+
+  constructor(
+    public readonly scene: Scene,
+    private image: TexImage,
+    private fillerData: FillerData,
+    private bestFitScale: number,
+  ) {
+    super()
+
+    const gl = scene.gl;
+
+
+    this.program = createProgram(gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!)!
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position")
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord")
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!
+    this.uScaleLoc = gl.getUniformLocation(this.program, "u_scale")!
+    this.uTexSizeLoc = gl.getUniformLocation(this.program, "u_texSize")!
+
+
+    this.texture = createTexture(gl, image, gl.LINEAR)
+
+
+    const centers: Array<Center> = []
+    const ratio = image.width / 10. / image.height
+    const textHeightRatio = Array(4)
+    for (var i = 0; i < 4; i++) {
+      textHeightRatio[i] = 2. / Math.sqrt(i * i * ratio * ratio + 1)
+    }
+
+
+    const magnify = 1.2
+    const minify = 0.8
+    var digits = 0
+
+
+    const config = fillerData.config
+
+    //const maxScale = fillerData.config.maxScale
+
+    const areaGroups = fillerData.data.areaGroups
+
+
+    for (var i = 0; i < areaGroups.length; i++) {
+      var group = areaGroups[i]
+      for (var j = 0; j < group.areas.length; j++) {
+        var area = group.areas[j]
+
+        // 剪枝, 如果已经上色,直接跳过
+        if (area.colored) continue
+
+        digits += area.center.label!.length
+        centers.push(area.center)
+        var fontHeight = area.center.radius * textHeightRatio[area.center.label!.length]
+
+        // 让文字大小线性放大或者缩小
+        var k = (fontHeight * config.maxScale) / config.visibleFontSize
+        if (k <= 1) {
+          k *= magnify
+        } else if (k > 1 && k < 1 / minify) {
+          k = ((1 / minify - magnify) / (1 / minify - 1)) * (k - 1) + magnify
+        } else if (k >= 1 / minify) {
+          k = minify * k + (1 / minify - 1)
+        }
+        fontHeight = k * config.visibleFontSize / config.maxScale
+
+        if (fontHeight * this.bestFitScale > config.visibleFontSize * 1.2) {
+          fontHeight = config.visibleFontSize * 1.2 / bestFitScale
+        }
+
+        area.center.fontHeight = fontHeight
+      }
+    }
+
+
+    centers.sort((a, b) => b.fontHeight - a.fontHeight)
+
+    this.digits = digits
+    this.centers = centers
+
+    this.vertexArray = new Float32Array(digits * 12)
+    this.texCoordArray = new Float32Array(digits * 12)
+    var offset = 0
+
+    for (var i = 0; i < centers.length; i++) {
+      var center = centers[i]
+      this.fillVertexArray(this.vertexArray, this.texCoordArray, offset, center, ratio)
+      offset += 12 * center.label.length
+      center.offset = offset / 2
+    }
+
+
+    //fillRectangle(this.vertexArray, 0, 0, 0, width, height )
+    //fillRectangle(this.texCoordArray, 0, 0, 0, 1, 1)
+
+
+    this.vertexBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.vertexArray, gl.STATIC_DRAW);
+
+
+    this.texcoordBuffer = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, this.texCoordArray, gl.STATIC_DRAW);
+
+  }
+
+
+
+
+  override draw() {
+
+    var offset = 0
+    const maxScale = this.fillerData.config.maxScale - 0.2
+    const scale = this.scene.userMat[0]
+
+    if (scale > maxScale) {
+      offset = this.vertexArray.length / 2
+    } else {
+      for (var i = this.centers.length - 1; i >= 0; i--) {
+        if (this.centers[i].fontHeight * scale > this.fillerData.config.visibleFontSize) {
+          offset = this.centers[i].offset
+          break
+        }
+      }
+
+    }
+
+
+    const gl = this.scene.gl;
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0)
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix)
+
+    gl.uniform1f(this.uScaleLoc, this.scene.userMat[0])
+    //gl.uniform2f(this.uTexSizeLoc, 1. / this.texWidth, 1. / this.texHeight)
+
+    gl.activeTexture(gl.TEXTURE0)
+    gl.bindTexture(gl.TEXTURE_2D, this.texture)
+
+    gl.drawArrays(gl.TRIANGLES, 0, offset);
+
+  }
+
+
+  fillVertexArray(vertexArray: Float32Array, texCoordArray: Float32Array, offset: number, center: Center, ratio: number) {
+
+    const text = center.label
+    const size = text.length
+    const cellWidth = center.fontHeight * ratio
+    const cellHeight = center.fontHeight
+    const width = cellWidth * size
+    const left = center.x - width / 2
+    const top = center.y - cellHeight / 2
+
+    for (var i = 0; i < size; i++) {
+      fillRectangle(vertexArray, offset + 12 * i, left + i * cellWidth, top, cellWidth, cellHeight)
+      const index = parseInt(text.substring(i, i + 1))
+      fillRectangle(texCoordArray, offset + 12 * i, index * 0.1, 0, 0.1, 1)
+    }
+
+  }
+
+
+
+
+
+
+
+  toString(): string {
+    return `NumberLayer()`
+  }
+
+}
+
+
+
+

+ 242 - 0
src/filler/WorkLayer.ts

@@ -0,0 +1,242 @@
+import { LayerAB, Scene } from "../base/Scene";
+import { createProgram, createShader } from "../base/utils";
+import { AnimatableMask } from "./AnimatableMask";
+import { rectangleBuffer } from "./common";
+import { FillerData } from "./FillerData";
+
+export class WorkLayer extends LayerAB {
+  private vertexShaderCode = /*glsl*/ `
+    attribute vec2 a_position;
+    attribute vec2 a_texCoord;
+    uniform mat4 u_matrix;
+    varying vec2 v_texCoord;
+    void main() {
+      gl_Position = u_matrix * vec4(a_position, 0, 1);
+      v_texCoord = a_texCoord;
+    }
+
+  `;
+
+  private fragmentShaderCode = /*glsl*/ `
+    precision mediump float;
+    uniform sampler2D u_colored;
+    uniform sampler2D u_mask;
+    uniform vec2 u_pixelSize; 
+    varying vec2 v_texCoord;
+
+
+    const float w1 = 0.147761;
+    const float w2 = 0.118318; 
+    const float w3 = 0.0947416; 
+    
+    
+    vec4 GaussianBlur(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+      vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+      vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+      vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+    
+      vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+      vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+      vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+    
+      vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+      vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+      vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+    
+      return 
+        C00 + C01 + C02 +
+        C10 + C11 + C12 +
+        C20 + C21 + C22 ;
+    }
+
+        
+    vec4 GaussianBlurR(in sampler2D image, in vec2 texCoord, in vec2 pixelSize) {
+      vec4 C00 = texture2D(image, texCoord + vec2(-pixelSize.x, -pixelSize.y)) * w3 ;
+      vec4 C01 = texture2D(image, texCoord + vec2(0.0, -pixelSize.y)) * w2;
+      vec4 C02 = texture2D(image, texCoord + vec2(pixelSize.x, -pixelSize.y)) * w3 ;
+    
+      vec4 C10 = texture2D(image, texCoord + vec2(-pixelSize.x, 0.0)) * w2;
+      vec4 C11 = texture2D(image, texCoord + vec2(0.0, 0.0)) * w1;
+      vec4 C12 = texture2D(image, texCoord + vec2(pixelSize.x, 0.0)) * w2;
+    
+      vec4 C20 = texture2D(image, texCoord + vec2(-pixelSize.x, pixelSize.y)) * w3;
+      vec4 C21 = texture2D(image, texCoord + vec2(0.0, pixelSize.y)) * w2;
+      vec4 C22 = texture2D(image, texCoord + vec2(pixelSize.x, pixelSize.y)) * w3;
+
+      vec4 g =  
+        C00 + C01 + C02 +
+        C10 + C11 + C12 +
+        C20 + C21 + C22 ;
+
+      return g;
+    }
+
+
+    
+    void main() {
+        vec4 mask = GaussianBlurR(u_mask, v_texCoord, u_pixelSize);
+        vec4 colored = GaussianBlur(u_colored, v_texCoord, u_pixelSize);
+        //vec4 colored = texture2D(u_colored, v_texCoord);
+        //gl_FragColor = vec4(colored.rgb, mask.a);
+        if(mask.r >= 0.4) {
+            gl_FragColor = vec4(colored.rgb, 1);
+            //gl_FragColor = colored;
+        }else{
+            gl_FragColor = vec4(0, 0, 0, 0);
+        }
+    }
+    
+  `;
+
+  program: WebGLProgram;
+
+  positionBuffer: WebGLBuffer;
+  texCoordBuffer: WebGLBuffer;
+
+  aPositionLoc: number;
+  aTexcoordLoc: number;
+  uMatrixLoc: WebGLUniformLocation;
+  uMaskLoc: WebGLUniformLocation;
+  uColoredLoc: WebGLUniformLocation;
+  uPixelSizeLoc: WebGLUniformLocation;
+
+  animatableMask: AnimatableMask;
+
+  dispose() {
+    let gl = this.scene.gl;
+    this.animatableMask.dispose();
+    gl.deleteProgram(this.program);
+    gl.deleteBuffer(this.positionBuffer);
+    gl.deleteBuffer(this.texCoordBuffer);
+  }
+
+  constructor(
+    private scene: Scene,
+    private fillerData: FillerData,
+  ) {
+    super();
+
+    this.animatableMask = new AnimatableMask(scene, fillerData);
+
+    const gl = scene.gl;
+
+    this.program = createProgram(
+      gl,
+      createShader(gl, gl.VERTEX_SHADER, this.vertexShaderCode)!,
+      createShader(gl, gl.FRAGMENT_SHADER, this.fragmentShaderCode)!,
+    )!;
+
+    this.aPositionLoc = gl.getAttribLocation(this.program, "a_position");
+    this.aTexcoordLoc = gl.getAttribLocation(this.program, "a_texCoord");
+    this.uMatrixLoc = gl.getUniformLocation(this.program, "u_matrix")!;
+    this.uMaskLoc = gl.getUniformLocation(this.program, "u_mask")!;
+    this.uColoredLoc = gl.getUniformLocation(this.program, "u_colored")!;
+    this.uPixelSizeLoc = gl.getUniformLocation(this.program, "u_pixelSize")!;
+
+    this.positionBuffer = rectangleBuffer(
+      gl,
+      0,
+      0,
+      fillerData.width,
+      fillerData.height,
+    );
+    this.texCoordBuffer = rectangleBuffer(gl, 0, 0, 1, 1);
+  }
+
+  // 恢复游戏,已经有部分完成的
+  public initTask() {
+    this.animatableMask.initTask();
+    this.scene.invalidate();
+  }
+
+  override draw() {
+    console.log("WorkLayer draw()");
+    const gl = this.scene.gl;
+
+    gl.useProgram(this.program);
+
+    gl.enableVertexAttribArray(this.aPositionLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer);
+    gl.vertexAttribPointer(this.aPositionLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.enableVertexAttribArray(this.aTexcoordLoc);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer);
+    gl.vertexAttribPointer(this.aTexcoordLoc, 2, gl.FLOAT, false, 0, 0);
+
+    gl.uniformMatrix4fv(this.uMatrixLoc, false, this.scene.drawMatrix);
+    gl.uniform2f(
+      this.uPixelSizeLoc,
+      1 / this.fillerData.width,
+      1 / this.fillerData.height,
+    );
+
+    gl.uniform1i(this.uMaskLoc, 0);
+    gl.uniform1i(this.uColoredLoc, 1);
+
+    gl.activeTexture(gl.TEXTURE0);
+    gl.bindTexture(gl.TEXTURE_2D, this.animatableMask.texture);
+    gl.activeTexture(gl.TEXTURE1);
+    gl.bindTexture(gl.TEXTURE_2D, this.fillerData.colored);
+
+    gl.drawArrays(gl.TRIANGLES, 0, 6);
+  }
+
+  override preDraw(): void {
+    this.animatableMask.flush();
+  }
+
+  override tap(cx: number, cy: number, sx: number, sy: number): void {
+    let fd = this.fillerData;
+
+    if (fd.currentGroup == null) {
+      fd.callback?.onFillFailed();
+      return;
+    }
+
+    const hash = fd.data.groupAreaHash(fd.currentGroup);
+
+    let area = this.fillerData.getArea(cx, cy, 50, hash);
+    if (area != null) {
+      this.animatableMask.addArea(area, cx, cy);
+      fd.setColored(area, fd.currentGroup.color, cx, cy);
+
+      fd.callback?.onFillSuccess();
+
+      // 全部填完,自动切换到下一个group
+      if (fd.currentGroup.isAllColored) {
+        if (!fd.switchToNextGroup()) {
+          fd.callback?.onFinish();
+        }
+      }
+    }
+  }
+
+  override scale(scale: number): void {}
+
+  async replay() {
+    const gl = this.scene.gl;
+
+    gl.bindFramebuffer(gl.FRAMEBUFFER, this.animatableMask.fb);
+
+    // Clear the framebuffer
+    gl.clearColor(0, 0, 0, 0); // White
+    gl.clear(gl.COLOR_BUFFER_BIT);
+
+    // Bind the default framebuffer again
+    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+    const length = this.fillerData.taskList.length;
+    const duration = Math.max(2000 / length, 50);
+
+    for (let task of this.fillerData.taskList) {
+      let area = this.fillerData.data.areaHash.get(task);
+      if (area) {
+        this.animatableMask.addArea(area, area.center.x, area.center.y, 200);
+        this.scene.invalidate();
+        await delay(duration);
+      }
+    }
+  }
+}
+
+const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));

+ 255 - 0
src/filler/common.ts

@@ -0,0 +1,255 @@
+import { Rect, rectangleArray } from "../base/2d"
+
+export type TexImage = HTMLCanvasElement | HTMLImageElement
+
+
+// 设置项
+export interface Settings {
+  hintStyle: string, // 提示层样式
+  sound: boolean; // 是否开启声音
+  vibrate: boolean; // 是否开启震动
+  autoNext: boolean; // 自动切换到下一个颜色 
+}
+
+export class Color {
+
+  color: number
+  parts: Uint8Array
+
+  constructor(color: number) {
+    this.color = color
+    this.parts = new Uint8Array(4)
+    let u32 = new Uint32Array(this.parts.buffer)
+    u32[0] = color
+  }
+
+  toFloatArray(): Float32Array {
+    let p = this.parts;
+    let arr = new Float32Array(4)
+
+    arr[0] = p[0] / 255;
+    arr[1] = p[1] / 255;
+    arr[2] = p[2] / 255;
+    arr[3] = p[3] / 255;
+
+    return arr;
+  }
+
+
+  fillFloatArray(arr: Float32Array, offset: number, count: number = 1) {
+    let p = this.parts;
+    const r = p[0] / 255;
+    const g = p[1] / 255;
+    const b = p[2] / 255;
+    const a = p[3] / 255;
+
+    for (var i = 0; i < count; i++) {
+      arr[offset + 4 * i] = r
+      arr[offset + 4 * i + 1] = g
+      arr[offset + 4 * i + 2] = b
+      arr[offset + 4 * i + 3] = a
+    }
+  }
+
+
+
+
+
+  css(): string {
+    let p = this.parts
+    return `rgba(${p[0]},${p[1]},${p[2]},${p[3]})`
+  }
+
+
+  /**
+   * 计算颜色的灰阶值,越高表示颜色越浅
+   */
+  get gray(): number {
+    let p = this.parts;
+    let r = p[0] * 0.299;
+    let g = p[1] * 0.587;
+    let b = p[2] * 0.114;
+
+    let gray = r + g + b;
+
+    return gray;
+
+  }
+}
+
+
+export interface Center {
+  x: number,
+  y: number,
+  radius: number,
+
+  offset: number,
+  label: string,
+  fontHeight: number
+}
+
+
+
+/*
+export interface Area {
+  center: Center
+  rect: Rect
+  count: number
+  id: number
+  colored: boolean,
+}
+
+export interface AreaGroup {
+  areas: Array<Area>
+  color: number
+}*/
+
+
+export class Area {
+  constructor(
+    public center: Center,
+    public rect: Rect,
+    public id: number,
+    public colored: boolean
+  ) { }
+}
+
+export class AreaGroup {
+  constructor(
+    public areas: Array<Area>,
+    public color: number
+  ) { }
+
+  get isAllColored(): boolean {
+    return this.areas.every(a => a.colored)
+  }
+
+  get firstUncoloredArea(): Area | undefined {
+    return this.areas.find(a => !a.colored)
+  }
+
+  // group的完成进度百分比
+  get progressPercent(): number {
+    let doneCount = this.areas.filter(a => a.colored).length;
+    let allCount = this.areas.length;
+    let percent = doneCount * 100 / allCount;
+    return percent;
+  }
+}
+
+
+export type AreaGroups = Array<AreaGroup>
+
+
+
+export class Data {
+  readonly areaHash: Map<number, Area>
+  readonly groupHash: Map<number, AreaGroup>
+
+  constructor(public readonly areaGroups: AreaGroups) {
+    this.areaHash = new Map()
+    this.groupHash = new Map()
+    areaGroups.forEach(group => {
+      Object.setPrototypeOf(group, AreaGroup.prototype)
+      group.areas.forEach(area => {
+        Object.setPrototypeOf(area, Area.prototype)
+        this.areaHash.set(area.id, area)
+        this.groupHash.set(area.id, group)
+      })
+    })
+  }
+
+  get areaCount(): number {
+    return this.areaGroups.reduce((pre, cur) => pre + cur.areas.length, 0)
+  }
+
+  get maxAreaCountOfGroup(): number {
+    return this.areaGroups.reduce((pre, cur) => {
+      return Math.max(pre, cur.areas.length)
+    }, 0)
+  }
+
+
+  groupAreaHash(group: AreaGroup): Map<number, Area> {
+    return group.areas.reduce((pre, cur) => {
+      return pre.set(cur.id, cur)
+    }, new Map())
+  }
+
+  get coloredPercent(): number {
+    let coloredCount = [...this.areaHash.values()].reduce((count: number, area: Area) => {
+      return area.colored === true ? count + 1 : count;
+    }, 0)
+    return Math.round(coloredCount * 100 / this.areaHash.size);
+  }
+
+}
+
+
+
+
+
+
+
+export function createTexture(gl: WebGL2RenderingContext, image: TexImage, filterType: GLuint = gl.NEAREST): WebGLTexture {
+
+  // Create a texture.
+  let texture = gl.createTexture()!;
+  gl.bindTexture(gl.TEXTURE_2D, texture);
+
+  // Set the parameters so we can render any size image.
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterType);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterType);
+
+  //Upload the image into the texture.
+  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+  return texture;
+
+}
+
+
+export function createPatternTexture(gl: WebGL2RenderingContext, image: TexImage): WebGLTexture {
+
+  // Create a texture.
+  let texture = gl.createTexture()!;
+  gl.bindTexture(gl.TEXTURE_2D, texture);
+
+  // Set the parameters so we can render any size image.
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+
+  //Upload the image into the texture.
+  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+  return texture;
+
+}
+
+
+
+
+export function createReadFramebuffer(gl: WebGL2RenderingContext, texture: WebGLTexture): WebGLFramebuffer {
+  let fb = gl.createFramebuffer()!
+  gl.bindFramebuffer(gl.FRAMEBUFFER, fb)
+  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
+  let canRead = gl.checkFramebufferStatus(gl.FRAMEBUFFER) == gl.FRAMEBUFFER_COMPLETE
+  console.log(`canRead=${canRead}`)
+  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+  return fb
+}
+
+
+
+export function rectangleBuffer(gl: WebGL2RenderingContext, x: number, y: number, width: number, height: number): WebGLBuffer {
+  let arr = rectangleArray(x, y, width, height)
+  let buffer = gl.createBuffer()!
+  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
+  gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STATIC_DRAW)
+  gl.bindBuffer(gl.ARRAY_BUFFER, null)
+  return buffer
+}

+ 170 - 0
src/filler/createColored.ts

@@ -0,0 +1,170 @@
+import { fillRectangle, Rect } from "../base/2d";
+import { m4 } from "../base/m4";
+import { createProgram, createShader } from "../base/utils";
+import { Area, AreaGroup, Color, Data } from "./common";
+
+
+
+
+
+export function createColored(
+  gl: WebGLRenderingContext,
+  mapTexture: WebGLTexture,
+  data: Data,
+  width: number,
+  height: number,
+): WebGLTexture {
+
+
+  const vertexShaderCode = /*glsl*/ `
+      attribute vec2 a_position;
+      attribute vec2 a_texCoord;
+      attribute vec4 a_color;
+      attribute vec4 a_destColor;
+      uniform mat4 u_matrix;
+      varying vec2 v_texCoord;
+      varying vec4 v_color;
+      varying vec4 v_destColor;
+
+      void main() {
+        gl_Position = u_matrix * vec4(a_position, 0, 1);
+        v_texCoord = a_texCoord;
+        v_color = a_color;
+        v_destColor = a_destColor;
+      }
+
+    `;
+
+  const fragmentShaderCode = /*glsl*/ `
+      precision mediump float;
+      uniform sampler2D u_image;
+      //uniform vec4 u_color;
+      varying vec2 v_texCoord;
+      varying vec4 v_color;
+      varying vec4 v_destColor;
+
+      void main() {
+        vec4 color = texture2D(u_image, v_texCoord);
+        float dist = distance(color, v_color);
+        if(dist < 0.001) {
+          gl_FragColor = v_destColor;
+        }else{
+          gl_FragColor = vec4(0,0,0,0);
+        }
+      }
+
+    `
+
+
+  const program = createProgram(gl,
+    createShader(gl, gl.VERTEX_SHADER, vertexShaderCode)!,
+    createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderCode)!)!
+
+
+  const target = gl.createTexture()
+  gl.bindTexture(gl.TEXTURE_2D, target)
+  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
+
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+
+
+  const fb = gl.createFramebuffer()
+  gl.bindFramebuffer(gl.FRAMEBUFFER, fb)
+  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target, 0)
+
+
+  let aPositionLoc = gl.getAttribLocation(program, "a_position")
+  let aTexCoordLoc = gl.getAttribLocation(program, "a_texCoord")
+  let aColorIdLoc = gl.getAttribLocation(program, "a_color")
+  let aDestColorLoc = gl.getAttribLocation(program, "a_destColor")
+  let uMatrixLoc = gl.getUniformLocation(program, "u_matrix")
+
+
+  const count = data.areaCount
+  const vertexArray = new Float32Array(12 * count)
+  const texCoordArray = new Float32Array(12 * count)
+  const colorIdArray = new Float32Array(24 * count)
+  const destColorArray = new Float32Array(24 * count)
+
+  var vertexOffset = 0
+  var colorOffset = 0
+
+  for (var i = 0; i < data.areaGroups.length; i++) {
+    var group = data.areaGroups[i]
+    var destColor = new Color(group.color)
+    for (var j = 0; j < group.areas.length; j++) {
+      var area = group.areas[j]
+      fillRectangle(vertexArray, vertexOffset, area.rect.x, area.rect.y, area.rect.width, area.rect.height)
+      fillRectangle(texCoordArray, vertexOffset, area.rect.x / width, area.rect.y / height, area.rect.width / width, area.rect.height / height)
+      var colorId = new Color(area.id)
+      colorId.fillFloatArray(colorIdArray, colorOffset, 6)
+      destColor.fillFloatArray(destColorArray, colorOffset, 6)
+      vertexOffset += 12
+      colorOffset += 24
+    }
+  }
+
+
+
+
+  gl.useProgram(program)
+
+
+  let colorsBuffer = gl.createBuffer()
+  gl.bindBuffer(gl.ARRAY_BUFFER, colorsBuffer)
+  gl.bufferData(gl.ARRAY_BUFFER, colorIdArray, gl.STATIC_DRAW)
+  gl.enableVertexAttribArray(aColorIdLoc)
+  gl.vertexAttribPointer(aColorIdLoc, 4, gl.FLOAT, false, 0, 0);
+
+  let destColorsBuffer = gl.createBuffer()
+  gl.bindBuffer(gl.ARRAY_BUFFER, destColorsBuffer)
+  gl.bufferData(gl.ARRAY_BUFFER, destColorArray, gl.STATIC_DRAW)
+  gl.enableVertexAttribArray(aDestColorLoc)
+  gl.vertexAttribPointer(aDestColorLoc, 4, gl.FLOAT, false, 0, 0)
+
+  let vertexBuffer = gl.createBuffer()
+  gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
+  gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW)
+  gl.enableVertexAttribArray(aPositionLoc)
+  gl.vertexAttribPointer(aPositionLoc, 2, gl.FLOAT, false, 0, 0)
+
+  let textureBuffer = gl.createBuffer()
+  gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer)
+  gl.bufferData(gl.ARRAY_BUFFER, texCoordArray, gl.STATIC_DRAW)
+  gl.enableVertexAttribArray(aTexCoordLoc)
+  gl.vertexAttribPointer(aTexCoordLoc, 2, gl.FLOAT, false, 0, 0)
+
+
+  gl.viewport(0, 0, width, height)
+  gl.uniformMatrix4fv(uMatrixLoc, false, m4.projectionNoflipY(width, height))
+
+  gl.activeTexture(gl.TEXTURE0)
+  gl.bindTexture(gl.TEXTURE_2D, mapTexture)
+
+
+  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+  gl.enable(gl.BLEND);
+  gl.disable(gl.DEPTH_TEST);
+
+  gl.drawArrays(gl.TRIANGLES, 0, vertexArray.length / 2)
+
+  gl.disableVertexAttribArray(aColorIdLoc)
+  gl.disableVertexAttribArray(aTexCoordLoc)
+  gl.disableVertexAttribArray(aPositionLoc)
+  gl.disableVertexAttribArray(aDestColorLoc)
+
+  gl.deleteBuffer(vertexBuffer)
+  gl.deleteBuffer(textureBuffer)
+  gl.deleteBuffer(colorsBuffer)
+  gl.deleteBuffer(destColorsBuffer)
+
+  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
+
+  gl.deleteFramebuffer(fb)
+
+
+  return target!
+}

+ 47 - 0
src/filler/cta.ts

@@ -0,0 +1,47 @@
+/**
+ * CTA 按钮控制器
+ *
+ * - 页面加载后按钮常驻可见(工具栏之上)
+ * - 填色完成后调用 `ctaHighlight()` 放大 + 强脉冲,引导用户点击
+ * - 点击后通过 mraid 适配层跳转 Store
+ */
+
+import { openStoreUrl } from "./mraid";
+
+/** App Store / Google Play 落地页链接,按需替换 */
+// 正式上线时换回自己的 app:
+// const STORE_URL_IOS = "https://apps.apple.com/app/id1575480118";
+// const STORE_URL_ANDROID = "https://play.google.com/store/apps/details?id=com.pcoloring.art.puzzle.color.by.number";
+
+// 测试期间使用 PBN,避免产生无效转化
+const STORE_URL_IOS =
+  "https://apps.apple.com/gb/app/paint-by-number-coloring-games/id1420058690";
+const STORE_URL_ANDROID =
+  "https://play.google.com/store/apps/details?id=com.oakever.paintbynumber";
+
+function getStoreUrl(): string {
+  const ua = navigator.userAgent;
+  if (/android/i.test(ua)) return STORE_URL_ANDROID;
+  return STORE_URL_IOS; // iOS / 桌面 fallback
+}
+
+/**
+ * 初始化 CTA 按钮:绑定点击事件。
+ * 应在 DOM ready 后调用一次。
+ */
+export function initCta(): void {
+  const btn = document.getElementById("cta-btn");
+  if (!btn) return;
+  btn.addEventListener("click", () => {
+    openStoreUrl(getStoreUrl());
+  });
+}
+
+/**
+ * 填色完成后调用,放大按钮并加强脉冲动画,吸引用户点击。
+ */
+export function ctaHighlight(): void {
+  const btn = document.getElementById("cta-btn");
+  if (!btn) return;
+  btn.classList.add("cta-highlight");
+}

+ 114 - 0
src/filler/explosion.ts

@@ -0,0 +1,114 @@
+/**
+ * 爆破动画
+ */
+
+import { generateSimilarColors } from "../base/utils";
+
+
+/**
+ * 爆破微粒
+ */
+class Particle {
+  ctx: CanvasRenderingContext2D;
+  x: number;
+  y: number;
+  r: number;
+  dx: number;
+  dy: number;
+  color: string;
+  a: number;
+
+  /**
+   * 
+   * @param ctx 
+   * @param x 初始坐标x
+   * @param y 初始坐标y
+   * @param r 颗粒大小半径
+   * @param dx 每帧移动x坐标距离
+   * @param dy 每帧移动y坐标距离
+   * @param color 颜色
+   */
+  constructor(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, dx: number, dy: number, color: string) {
+    this.x = x;
+    this.y = y;
+    this.r = r;
+    this.dx = dx;
+    this.dy = dy;
+    this.color = color;
+    this.a = 1;
+    this.ctx = ctx;
+  }
+
+  draw() {
+    let ctx = this.ctx;
+
+    ctx.save();
+    ctx.globalAlpha = this.a;
+    ctx.fillStyle = this.color;
+
+    ctx.beginPath();
+    ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2, false);
+    ctx.fill();
+
+    ctx.restore();
+  }
+
+  update() {
+    this.draw();
+    this.a -= 1 / 120;
+    this.x += this.dx;
+    this.y += this.dy;
+  }
+}
+
+export default class Explosion {
+  canvas: HTMLCanvasElement;
+  ctx: CanvasRenderingContext2D;
+  onend: Function;
+  particles: Particle[] = [];
+
+  constructor(canvas: HTMLCanvasElement, color: string, particleNum: number, onend: Function) {
+    this.canvas = canvas;
+    this.canvas.width = Math.floor(this.canvas.offsetWidth * window.devicePixelRatio);
+    this.canvas.height = Math.floor(this.canvas.offsetHeight * window.devicePixelRatio);
+    this.ctx = this.canvas.getContext("2d")!;
+    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+    this.initParticles(color, particleNum);
+    this.onend = onend;
+  }
+
+
+  /**
+   * 用requestAnimationFrame试试==>果然效果好多了,而且在低端机上效果明显
+   */
+  explode() {
+    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+    this.particles.forEach((particle, i) => {
+      if (particle.a <= 0) {
+        this.particles.splice(i, 1);
+      } else {
+        particle.update();
+      }
+    })
+
+    if (this.particles.length == 0) {
+      this.onend && this.onend();
+      return;
+    }
+    requestAnimationFrame(this.explode.bind(this));
+  }
+
+  private initParticles(color: string, particleNum: number) {
+    let colors = generateSimilarColors(color, particleNum);
+    for (let i = 0; i < particleNum; i++) {
+      let x = this.canvas.width / 2 + (Math.random() - 0.5) * (Math.random() * 10);
+      let y = this.canvas.height / 2 + (Math.random() - 0.5) * (Math.random() * 10);
+      let r = (Math.floor(Math.random() * 4) + 1) * window.devicePixelRatio;
+      let dx = (Math.random() - 0.5) * (Math.random() * 8) * window.devicePixelRatio;
+      let dy = (Math.random() - 0.5) * (Math.random() * 6) * window.devicePixelRatio;
+      let c = colors[i];
+      let particle = new Particle(this.ctx, x, y, r, dx, dy, c);
+      this.particles.push(particle);
+    }
+  }
+}

+ 590 - 0
src/filler/index.ts

@@ -0,0 +1,590 @@
+import { BgLayer, BgType } from "../base/BgLayer";
+import { BorderLayer } from "../base/BorderLayer";
+import { BoxLayer } from "../base/BoxLayer";
+import { DebugLayer } from "../base/DebugLayer";
+import { FrameLayer } from "../base/FrameLayer";
+import { TouchTracker } from "../base/Gesture";
+import { ImageShaders } from "../base/ImageShaders";
+import { Padding, Scene } from "../base/Scene";
+import { TextureLayer } from "../base/TextureLayer";
+import { loadImage } from "../base/utils";
+import AudioPlayer, { AudioType } from "./Audio";
+import { AreaGroup, AreaGroups, Color, Settings } from "./common";
+import Explosion from "./explosion";
+import {
+  FillerConfig,
+  FillerData,
+  FillerResource,
+  FillerCallback,
+} from "./FillerData";
+import { FillerScene } from "./FillerScene";
+import { HintLayer } from "./HintLayer";
+import { LineArtLayer } from "./LineArtLayer";
+import { NumberLayer } from "./NumberLayer";
+import { WorkLayer } from "./WorkLayer";
+import { LoadingController } from "./LoadingController";
+import { FingerHint } from "./FingerHint";
+import JSConfetti from "js-confetti";
+import { initCta, ctaHighlight } from "./cta";
+import { initAdPlatform } from "./mraid";
+
+// 静态导入资源,Vite 构建时会将它们转为 data URI 内联进 HTML
+import configRaw from "/assets/res/6a18f7d9957ac783bad75479/config.json?raw";
+import pageUrl from "/assets/res/6a18f7d9957ac783bad75479/page.png?url";
+import mapUrl from "/assets/res/6a18f7d9957ac783bad75479/map.png?url";
+import specialUrl from "/assets/res/6a18f7d9957ac783bad75479/special.jpeg?url";
+import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";
+import fingerUrl from "/assets/img/finger.png?url";
+import logoUrl from "/assets/img/logo.png?url";
+import logoTxtUrl from "/assets/img/logo-txt.png?url";
+import coloringPagesUrl from "/assets/img/coloring-pages.png?url";
+import slogonUrl from "/assets/img/slogon.png?url";
+
+document.body.onload = function () {
+  initCta();
+  init();
+};
+
+async function test() {
+  let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
+  canvas.width = canvas.clientWidth * window.devicePixelRatio;
+  canvas.height = canvas.clientHeight * window.devicePixelRatio;
+  let resource = await loadResource();
+  let ctx = canvas.getContext("2d")!;
+  ctx.fillStyle = "red";
+  ctx.fillRect(0, 0, canvas.width, canvas.height);
+  ctx.drawImage(resource.page, 0, 0);
+}
+
+async function init() {
+  const loadingController = new LoadingController({
+    minDisplayTime: 1000, // 最小显示时间1秒
+    fadeDuration: 300, // 淡出动画300ms
+  });
+
+  loadingController.show();
+
+  let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
+  let gl = canvas.getContext("webgl2", {
+    premultipliedAlpha: false,
+  }) as WebGL2RenderingContext;
+  let pixelRatio = window.devicePixelRatio;
+
+  /** 根据 game-area 的实际渲染尺寸同步 canvas 像素尺寸 */
+  function syncCanvasSize() {
+    const gameArea = document.getElementById("game-area")!;
+    const w = Math.round(gameArea.clientWidth * pixelRatio);
+    const h = Math.round(gameArea.clientHeight * pixelRatio);
+    if (canvas.width !== w || canvas.height !== h) {
+      canvas.width = w;
+      canvas.height = h;
+      scene?.updateViewport();
+    }
+  }
+
+  canvas.width = canvas.clientWidth * pixelRatio;
+  canvas.height = canvas.clientHeight * pixelRatio;
+  let scene = new FillerScene(gl, pixelRatio);
+
+  let audio = new AudioPlayer();
+
+  // 加载设置项,学个新语法 ...
+  let settings: Settings = {
+    hintStyle: "default",
+    sound: true,
+    vibrate: true,
+    autoNext: true,
+  };
+
+  window.addEventListener("resize", () => syncCanvasSize());
+  document
+    .querySelector("#toggleTestLayer")
+    ?.addEventListener("click", () => scene.toggleTestLayer());
+
+  document.addEventListener("click", (e: MouseEvent) => onDocumentClick(e));
+  document.addEventListener("touchstart", (e: TouchEvent) =>
+    onDocumentClick(e),
+  );
+
+  let resource = await loadResource();
+
+  loadingController.hide();
+  initAdPlatform();
+
+  // 设置固定顶栏 logo(图标 + 文字)
+  (document.getElementById("app-logo") as HTMLImageElement).src = logoUrl;
+  (document.getElementById("app-logo-txt") as HTMLImageElement).src =
+    logoTxtUrl;
+
+  let taskList: number[] = [];
+
+  // fingerHint 先声明,init 末尾赋值后回调中就能使用
+  let fingerHint: FingerHint | null = null;
+
+  let fillerData = new FillerData(
+    new FillerConfig(settings),
+    resource,
+    gl,
+    taskList,
+    {
+      onFillFailed() {
+        console.log("填充失败");
+        fingerHint?.onUserInteraction();
+      },
+      onFillSuccess() {
+        console.log("填充成功");
+        fingerHint?.onUserInteraction();
+        if (fillerData.config.settings.vibrate) {
+          vibrate();
+        }
+        cssAdjustProgress(fillerData.data.coloredPercent);
+        cssSetColorProgress(
+          fillerData.currentGroupIndex,
+          fillerData.currentGroup!.progressPercent,
+        );
+        if (fillerData.currentGroup?.isAllColored) {
+          if (fillerData.config.settings.sound) {
+            audio.playAudio(AudioType.ColorDone);
+          }
+          cssColorDone(fillerData);
+        }
+      },
+      onSwitchGroup() {
+        let currentGroupIndex = fillerData.currentGroupIndex;
+        console.log(`切换到下一个group ${currentGroupIndex}`);
+        cssSelectColor(currentGroupIndex);
+        cssAdjustScroll(fillerData);
+      },
+      onFinish() {
+        fingerHint?.stop();
+        cssOnFinish(scene, workLayer, audio);
+      },
+    },
+  );
+
+  scene.fillerData = fillerData;
+
+  console.log("resource", resource);
+
+  /** 根据横/竖屏设置 canvas 内容 padding(为 UI 元素留出空间) */
+  function updateContentPadding() {
+    const isLandscape = window.innerWidth > window.innerHeight;
+    if (isLandscape) {
+      // 横屏:右侧栏已占 42%,canvas 区域更方正,只需留出底部工具栏空间
+      scene.setContentPadding(new Padding(20, 90, 20, 140));
+    } else {
+      // 竖屏:顶部留 logo(140px),底部留工具栏+CTA(约 330px)
+      scene.setContentPadding(new Padding(50, 250, 50, 330));
+    }
+  }
+  updateContentPadding();
+  window.addEventListener("resize", updateContentPadding);
+
+  //let page = await loadImage('/webgl/page.png')
+  //let page = await loadImage('/assets/resources/friend/page_gray.png')
+
+  let width = fillerData.width;
+  let height = fillerData.height;
+  scene.setContentSize(width, height);
+
+  if (resource.bg) {
+    scene.addLayer(
+      new BgLayer(scene, resource.bg, scene.width, scene.height, BgType.Repeat),
+    );
+  }
+  scene.addLayer(new BoxLayer(scene, 0, 0, width, height));
+
+  scene.addLayer(new HintLayer(scene, fillerData));
+  scene.addLayer(new NumberLayer(scene, resource.numberImage, fillerData, 1));
+
+  let workLayer = new WorkLayer(scene, fillerData);
+
+  scene.addLayer(workLayer);
+
+  //scene.addLayer(new TextureLayer(scene, workLayer.mask.texture, fillerData.width, fillerData.height, fillerData.width, fillerData.height))
+
+  scene.addLayer(new LineArtLayer(scene, resource.page, width, height));
+
+  scene.addLayer(
+    new BorderLayer(
+      scene,
+      0,
+      0,
+      fillerData.width,
+      fillerData.height,
+      0xff222222,
+    ),
+  );
+
+  // let mask = new Mask(gl, fillerData)
+  // resource.config[0].areas.forEach(area => mask.addArea(area))
+  // mask.flush()
+
+  //scene.addTestLayer(new TextureLayer(scene, mask.texture, fillerData.width, fillerData.height, width, height, ImageShaders.CommonGaussianBlurCut))
+  //scene.addTestLayer(new TextureLayer(scene, mask.texture, fillerData.width, fillerData.height, width, height, ))
+
+  //scene.addLayer(new TextureLayer(scene, hintLayer.mask.texture, fillerData.width, fillerData.height, width, height, ImageShaders.CommonGaussianBlur  ))
+  //scene.addLayer(new DebugLayer(scene))
+
+  //scene.addLayer(new ImageLayer(scene, resource.page, width, height, gl.LINEAR, ImageShaders.CommonGaussianBlur))
+  //scene.addLayer(new TextureLayer(scene, fillerData.mapTexure, width, height, width, height))
+
+  createButtons(fillerData);
+  cssAdjustProgress(fillerData.data.coloredPercent);
+
+  workLayer.initTask();
+
+  if (fillerData.data.coloredPercent >= 100) {
+    cssOnFinish(scene, workLayer, null);
+  } else {
+    // 创建手指提示,全图未完成时启动引导
+    fingerHint = new FingerHint(scene, fillerData, fingerUrl);
+    fingerHint.start();
+  }
+}
+
+async function loadResource(): Promise<FillerResource> {
+  const config = JSON.parse(configRaw) as AreaGroups;
+  const [page, map, special, numberImage] = await Promise.all([
+    loadImage(pageUrl),
+    loadImage(mapUrl),
+    loadImage(specialUrl),
+    loadImage(numberFontUrl),
+  ]);
+
+  return new FillerResource(
+    config,
+    page as HTMLImageElement,
+    map as HTMLImageElement,
+    numberImage as HTMLImageElement,
+    [],
+    special as HTMLImageElement,
+  );
+}
+
+function createButtons(fillerData: FillerData) {
+  let data = fillerData.data.areaGroups;
+  let htmlUndoned = [];
+  let htmlDone = [];
+  let html = [];
+  let areaGroup: AreaGroup;
+  let color: Color;
+  for (var i = 0; i < data.length; i++) {
+    areaGroup = data[i];
+    color = new Color(areaGroup.color);
+    if (areaGroup.isAllColored) {
+      htmlDone.push(
+        `<div id="color-btn-container-${i}" class="color-btn-container" onclick="selectColor(this, event, ${i})">`,
+      );
+      htmlDone.push(
+        `<svg id="color-btn-progress-ring-${i}" class="color-btn-progress-ring" viewBox="0 0 48 48"><circle class="color-btn-progress-ring-track" cx="50%" cy="50%" r="45%" fill="transparent" stroke="#fff" stroke-width="5%" /> <circle id="color-btn-progress-ring-value-${i}" class="color-btn-progress-ring-value" cx="50%" cy="50%" r="45%" fill="transparent" stroke="#2ecc71" stroke-width="5%" stroke-linecap="round" /></svg>`,
+      );
+      htmlDone.push(
+        `<div id="color-btn-${i}" class="color-btn" style="background-color:${color.css()}; color: ${color.gray < 192 ? "white" : "black"}">`,
+      );
+      htmlDone.push(`✓`);
+      htmlDone.push(`</div>`);
+      htmlDone.push(`</div>`);
+    } else {
+      if (
+        areaGroup === fillerData.currentGroup ||
+        areaGroup.progressPercent > 0.0
+      ) {
+      } // 需要展示circle progress
+      htmlUndoned.push(
+        `<div id="color-btn-container-${i}" class="color-btn-container" onclick="selectColor(this, event, ${i})">`,
+      );
+      htmlUndoned.push(
+        `<svg id="color-btn-progress-ring-${i}" class="color-btn-progress-ring" viewBox="0 0 48 48"><circle class="color-btn-progress-ring-track" cx="50%" cy="50%" r="45%" fill="transparent" stroke="#fff" stroke-width="5%" /> <circle id="color-btn-progress-ring-value-${i}" class="color-btn-progress-ring-value" cx="50%" cy="50%" r="45%" fill="transparent" stroke="#2ecc71" stroke-width="5%" stroke-linecap="round" /></svg>`,
+      );
+      htmlUndoned.push(
+        `<div id="color-btn-${i}" class="color-btn" style="background-color:${color.css()}; color: ${color.gray < 192 ? "white" : "black"}">`,
+      );
+      htmlUndoned.push(`${i + 1}`);
+      htmlUndoned.push(`</div>`);
+      htmlUndoned.push(`</div>`);
+    }
+  }
+  html = htmlUndoned.concat(htmlDone);
+  let c = document.querySelector("#color-btns");
+  if (c != null) {
+    c.innerHTML = html.join("");
+  }
+
+  // 选中当前color
+  cssSelectColor(fillerData.currentGroupIndex);
+
+  // 调整每个color item的进度
+  for (var i = 0; i < data.length; i++) {
+    let areaGroup = data[i];
+    let percent = areaGroup.progressPercent;
+    cssInitColorProgress(i, percent);
+  }
+
+  (window as any).selectColor = function (
+    el: HTMLElement,
+    event: MouseEvent,
+    i: number,
+  ) {
+    console.log("select", el, event, i);
+    fillerData.setCurrentGroup(i);
+    document.querySelectorAll(".color-btn-container").forEach((item) => {
+      item.classList.remove("color-btn-container-selected");
+    });
+    el.classList.add("color-btn-container-selected");
+  };
+}
+
+/**
+ * 调整整体完成进度条
+ */
+function cssAdjustProgress(percent: number) {
+  let progressElem = document.getElementById("progress") as HTMLDivElement;
+  let percentElem = document.getElementById("percent") as HTMLDivElement;
+
+  percentElem.innerText = `${percent}%`;
+
+  progressElem.style.width = `${percent}%`;
+}
+
+// 选中某个color, 自动切换到下一个颜色的时候触发调用
+function cssSelectColor(i: number) {
+  document.querySelectorAll(".color-btn-container").forEach((item) => {
+    item.classList.remove("color-btn-container-selected");
+  });
+  let elem = document.getElementById(`color-btn-container-${i}`);
+  elem?.classList.add("color-btn-container-selected");
+}
+
+// 设置color button的进度,即areagroup的进度
+function cssSetColorProgress(i: number, percent: number) {
+  const container = document.getElementById(`color-btn-container-${i}`)!;
+  const progressCircle = document.getElementById(
+    `color-btn-progress-ring-value-${i}`,
+  )!;
+
+  const containerWidth = container.clientWidth;
+  const radius = containerWidth * 0.45;
+  const circumference = 2 * Math.PI * radius;
+  const offset = circumference - (percent * circumference) / 100;
+  progressCircle.style.strokeDashoffset = offset.toString();
+
+  if (percent >= 100) {
+    // 如果此color已经完成,文本变为✓, 同时移动到队列后面
+    const item = document.getElementById(`color-btn-${i}`)!;
+    item.innerText = "✓";
+  }
+}
+
+// 初始化color button进度
+function cssInitColorProgress(i: number, percent: number) {
+  const container = document.getElementById(`color-btn-container-${i}`)!;
+  const progressCircle = document.getElementById(
+    `color-btn-progress-ring-value-${i}`,
+  )!;
+
+  // 通过容器尺寸计算 SVG 半径
+  const containerWidth = container.clientWidth;
+  const radius = containerWidth * 0.45; // 对应 r="45%"
+
+  // 计算周长并设置属性
+  const circumference = 2 * Math.PI * radius;
+  const offset = circumference - (percent * circumference) / 100;
+  progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;
+  progressCircle.style.strokeDashoffset = offset.toString();
+}
+
+// 调整滚动条
+function cssAdjustScroll(fd: FillerData) {
+  let wrapper = document.getElementById("color-btns")!;
+  let count =
+    document.body.clientWidth /
+    (wrapper.scrollWidth / fd.data.areaGroups.length); // 视窗内显示了多少个元素
+  let width = document.body.clientWidth / count; // 每个元素占用的宽度
+  // 计算位置
+  let scrollpos =
+    (wrapper.scrollWidth / fd.data.areaGroups.length) *
+      (fd.currentGroupIndex - fd.doneBeforeCount) -
+    (count * width) / 2 +
+    width / 2;
+  scrollpos = scrollpos < 0 ? 0 : scrollpos;
+  // wrapper.scrollLeft = scrollpos ; // 不要直接设置scrollLeft,太突然没效果
+  try {
+    wrapper.scroll({ left: scrollpos, top: 0, behavior: "smooth" }); // 发现有些低端机不支持
+  } catch (e) {
+    console.log(e);
+    wrapper.scrollLeft = scrollpos;
+  }
+}
+
+/**
+ * 数字item爆破动画
+ */
+function cssExplosionAnimation(
+  numElem: HTMLElement,
+  color: string,
+  onfinish: Function,
+) {
+  let aniCanvas = document.createElement("canvas");
+  aniCanvas.className = "finish-ani-canvas";
+  let coords = numElem.getBoundingClientRect();
+  aniCanvas.style.left = `${coords.left - 50}px`;
+  aniCanvas.style.top = `${coords.top - 50}px`;
+  aniCanvas.style.width = `${coords.width + 100}px`;
+  aniCanvas.style.height = `${coords.height + 100}px`;
+  document.body.append(aniCanvas);
+
+  let explosion = new Explosion(aniCanvas, color, 50, () => {
+    onfinish();
+    aniCanvas.remove();
+  });
+  explosion.explode();
+}
+
+// 某个颜色填充完毕
+function cssColorDone(fillerData: FillerData) {
+  let i = fillerData.currentGroupIndex;
+  let color = new Color(fillerData.currentGroup!.color).css();
+
+  let wrapper = document.getElementById("color-btns")!;
+  let buttonWrap = document.getElementById(`color-btn-container-${i}`)!;
+
+  setTimeout(() => {
+    //等环形进度条走完,开始爆破动画
+    buttonWrap.style.visibility = "hidden";
+    cssExplosionAnimation(buttonWrap, color, () => {
+      // 爆破动画结束后,将元素移至末尾
+      wrapper.lastElementChild!.after(buttonWrap);
+      buttonWrap.style.visibility = "visible";
+    });
+  }, 300);
+}
+
+// 全部填完
+function cssOnFinish(
+  scene: FillerScene,
+  workLayer: WorkLayer,
+  audio: AudioPlayer | null,
+) {
+  // 隐藏toolbar
+  const toolbarBottom = document.getElementById(
+    "toolbar-bottom",
+  ) as HTMLDivElement;
+  // 爆破动画结束后隐藏元素
+  setTimeout(() => {
+    if (scene.fillerData!.config.settings.sound) {
+      audio?.playAudio(AudioType.AllDone);
+    }
+    scene.resetToResult();
+    toolbarBottom.classList.add("hidden-toolbar-bottom"); // 添加 hidden 类,触发动画
+    // 动画结束后隐藏元素
+    setTimeout(() => {
+      // toolbarBottom.style.display = 'none';
+      scene.addLayer(
+        new FrameLayer(
+          scene,
+          -4,
+          -4,
+          scene.fillerData!.width + 8,
+          scene.fillerData!.height + 8,
+          0xff9bceed,
+          16,
+        ),
+      );
+      workLayer.replay().then(() => showPromoScreen());
+    }, 500); // 过渡动画持续 0.5 秒
+  }, 300); // 粒子爆破动画持续 0.3 秒
+
+  setTimeout(() => {
+    confetti();
+  }, 300);
+}
+
+function showPromoScreen() {
+  const canvas = document.getElementById("canvas") as HTMLCanvasElement;
+  const promoScreen = document.getElementById("promo-screen") as HTMLDivElement;
+  const promoColoring = document.getElementById(
+    "promo-coloring",
+  ) as HTMLImageElement;
+  const promoSlogon = document.getElementById(
+    "promo-slogon",
+  ) as HTMLImageElement;
+
+  // 预设图片资源
+  promoColoring.src = coloringPagesUrl;
+  promoSlogon.src = slogonUrl;
+
+  // 1. 将画布缩小至消失
+  canvas.classList.add("canvas-shrink-out");
+
+  // 2. canvas 过渡完成后(~600ms)展示宣传界面 + logo 淡入
+  setTimeout(() => {
+    canvas.style.display = "none";
+    promoScreen.classList.add("visible");
+    // 双 rAF:确保浏览器渲染初始态后再触发 transition
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        promoColoring.classList.add("animate-in");
+        promoSlogon.classList.add("animate-in");
+        // 宣传界面出现后强化 CTA 按钮
+        ctaHighlight();
+      });
+    });
+  }, 620);
+}
+
+function onHint(audio: AudioPlayer, scene: FillerScene) {
+  if (scene.fillerData?.config.settings.sound) {
+    audio.playAudio(AudioType.Hint);
+  }
+  scene.hint();
+}
+
+function goback() {
+  if (document.referrer.includes(window.location.hostname)) {
+    window.history.back();
+  } else {
+    window.location.href = "/"; // 无来源时跳首页
+  }
+}
+
+function vibrate() {
+  // 判断浏览器是否支持震动
+  var supportVibrate = "vibrate" in navigator;
+  console.log("supportVibrate: " + supportVibrate);
+  if (supportVibrate) {
+    navigator.vibrate(30);
+  }
+}
+
+function showToast(message: string) {
+  const toast = document.getElementById("toast");
+  if (toast) {
+    toast.textContent = message;
+    toast.classList.remove("toast-hidden");
+    setTimeout(() => toast.classList.add("toast-hidden"), 2500);
+  }
+}
+
+// 撒花动画,使用js-confetti库
+function confetti() {
+  const jsConfetti = new JSConfetti();
+  jsConfetti.addConfetti();
+}
+
+// 点击其他区域,隐藏action-sheet
+function onDocumentClick(evt: Event) {
+  let elem = evt.target as HTMLElement;
+  let setting = document.getElementById("setting") as HTMLDivElement;
+  if (setting == elem || setting.contains(elem)) {
+    return;
+  }
+  let actionSheet = document.getElementById("action-sheet") as HTMLDivElement;
+  if (actionSheet.style.bottom == "0px") {
+    actionSheet.style.bottom = "-1000px";
+  }
+}
+
+function onActionSheetClick(evt: Event) {
+  evt.stopPropagation();
+}

+ 71 - 0
src/filler/mraid.ts

@@ -0,0 +1,71 @@
+/**
+ * MRAID 2.0 轻量适配层
+ *
+ * 运行时 feature-detect window.mraid,存在则通过 MRAID API 跳转,
+ * 否则 fallback 到 window.open。
+ *
+ * 平台覆盖:Applovin MAX、Unity Ads、IronSource 等注入 mraid.js 的环境。
+ */
+
+declare global {
+  interface Window {
+    mraid?: Mraid;
+    /** Applovin EXIT API */
+    ExitApi?: { exit: () => void };
+    /** Unity DAPI */
+    dapi?: { gameReady: () => void; isReady: () => boolean; addEventListener: (event: string, cb: () => void) => void };
+  }
+}
+
+interface Mraid {
+  getState(): string;
+  open(url: string): void;
+  useCustomClose(use: boolean): void;
+  addEventListener(event: string, cb: () => void): void;
+  removeEventListener(event: string, cb: () => void): void;
+}
+
+/**
+ * 初始化广告平台生命周期。
+ * 应在页面资源加载完毕后调用一次。
+ */
+export function initAdPlatform(): void {
+  // Unity DAPI
+  if (window.dapi) {
+    if (window.dapi.isReady()) {
+      window.dapi.gameReady();
+    } else {
+      window.dapi.addEventListener("ready", () => window.dapi!.gameReady());
+    }
+    return;
+  }
+
+  // MRAID(Applovin / IronSource / ...)
+  if (window.mraid) {
+    const onReady = () => {
+      window.mraid!.useCustomClose(false);
+    };
+    const state = window.mraid.getState();
+    if (state === "loading") {
+      window.mraid.addEventListener("ready", onReady);
+    } else {
+      onReady();
+    }
+  }
+}
+
+/**
+ * 跳转到 Store 页面(CTA 点击时调用)。
+ * 优先使用 MRAID,其次 Applovin ExitApi,最后 window.open。
+ */
+export function openStoreUrl(url: string): void {
+  if (window.mraid) {
+    window.mraid.open(url);
+    return;
+  }
+  if (window.ExitApi) {
+    window.ExitApi.exit();
+    return;
+  }
+  window.open(url, "_blank");
+}

+ 200 - 0
src/filler/play.ts

@@ -0,0 +1,200 @@
+import { BgLayer, BgType } from "../base/BgLayer";
+import { BorderLayer } from "../base/BorderLayer";
+import { BoxLayer } from "../base/BoxLayer";
+import { Padding, Scene } from "../base/Scene";
+import { loadImage } from "../base/utils";
+import { AreaGroup, AreaGroups, Color, Settings } from "./common";
+import {
+  FillerConfig,
+  FillerData,
+  FillerResource,
+  FillerCallback,
+} from "./FillerData";
+import { FillerScene } from "./FillerScene";
+import { LineArtLayer } from "./LineArtLayer";
+import { WorkLayer } from "./WorkLayer";
+
+document.body.onload = function () {
+  function extractIdRegex(url: string) {
+    const regex = /\/share\/([\w-]+)/;
+    const match = url.match(regex);
+    return match ? match[1] : null;
+  }
+
+  let currentUrl = window.location.href;
+  console.log("current url:", currentUrl);
+  let host = currentUrl.substring(0, currentUrl.indexOf("/share"));
+  let id = extractIdRegex(currentUrl);
+  console.log("host:", host);
+  console.log("id:", id);
+
+  // 从url参数中获取uuid
+  const urlParams = new URLSearchParams(window.location.search);
+  let uuid: string | null = urlParams.get("u") || urlParams.get("uuid"); // 尝试从 URL 参数中获取 UUID
+  // if (!uuid) return;
+
+  if (!id) id = "test";
+
+  let zipUrl = `${host}/proxy/zips/v2/number_mini/1501/${id}.zip`;
+  // let zipUrl = `http://localhost/66657ef03ba2da1daebcdd95.zip`;  // 本地跑docker nginx试试跨域
+  if (host.includes("art.pcoloring.com")) {
+    // 线上不走代理,直接请求pcoloring
+    zipUrl = `https://pcoloring.com/zips/v2/number_mini/1501/${id}.zip`; // pcoloring.com 已经增加了art.pcoloring.com的跨域许可
+    // zipUrl = `https://d2mb6s2cy1zg97.cloudfront.net/zips/v2/number_mini/1501${id}.zip`; // 直接访问cdn还不行
+  }
+  // console.log("zipUrl:", zipUrl);
+  // init(`http://localhost:6889/proxy/zips/v2/number_mini/1501/6735bd3ab494234e4abc1db1.zip`);
+  // init(`http://localhost:6889/proxy/zips/v2/number_mini/1501/${id}.zip`);
+  init(id, uuid, zipUrl);
+};
+
+async function test() {
+  let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
+  canvas.width = canvas.clientWidth * window.devicePixelRatio;
+  canvas.height = canvas.clientHeight * window.devicePixelRatio;
+  let resource = await loadResource();
+  let ctx = canvas.getContext("2d")!;
+  ctx.fillStyle = "red";
+  ctx.fillRect(0, 0, canvas.width, canvas.height);
+  ctx.drawImage(resource.page, 0, 0);
+}
+
+async function init(id: string, uuid: string | null, zipUrl: string) {
+  console.log(id, uuid, zipUrl);
+
+  // 先从服务器获取tasklist,如果获取不到,后面的都没有意义,可以直接退出
+  let taskList: number[] = [];
+  if (uuid) {
+    taskList = await fetchTasklistFromRemote(id, uuid);
+  }
+
+  let playButton = document.querySelector("#play-button") as HTMLDivElement;
+  let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
+  let gl = canvas.getContext("webgl2", {
+    premultipliedAlpha: false,
+  }) as WebGL2RenderingContext;
+  let pixelRatio = window.devicePixelRatio;
+
+  canvas.width = canvas.clientWidth * pixelRatio;
+  canvas.height = canvas.clientHeight * pixelRatio;
+  let scene = new FillerScene(gl, pixelRatio, window, false);
+
+  let resource = await loadResource();
+
+  // 没有从服务器上获取到用户uuid对应的tasklist,那就用默认顺序
+  if (!taskList || taskList.length <= 0) {
+    taskList = [];
+    for (let areaGroup of resource.config) {
+      for (let area of areaGroup.areas) {
+        taskList.push(area.id);
+      }
+    }
+  }
+
+  playButton.style.zIndex = "200";
+
+  // 加载设置项,学个新语法 ...
+  let settings: Settings = {
+    hintStyle: "default",
+    sound: true,
+    vibrate: true,
+    autoNext: true,
+    ...JSON.parse(localStorage.getItem("settings") || "{}"),
+  };
+
+  let fillerData = new FillerData(
+    new FillerConfig(settings),
+    resource,
+    gl,
+    taskList,
+  );
+
+  scene.fillerData = fillerData;
+
+  console.log("resource", resource);
+  scene.setContentPadding(new Padding(0, 0, 0, 0));
+
+  let width = fillerData.width;
+  let height = fillerData.height;
+  scene.setContentSize(width, height);
+
+  if (resource.bg) {
+    scene.addLayer(
+      new BgLayer(scene, resource.bg, scene.width, scene.height, BgType.Repeat),
+    );
+  }
+  scene.addLayer(new BoxLayer(scene, 0, 0, width, height));
+
+  let workLayer = new WorkLayer(scene, fillerData);
+
+  scene.addLayer(workLayer);
+
+  scene.addLayer(new LineArtLayer(scene, resource.page, width, height));
+
+  workLayer.initTask();
+
+  playButton.addEventListener("click", () => onPlay(workLayer));
+
+  // 直接自动播放,不用用户点击
+  onPlay(workLayer);
+}
+
+async function fetchTasklistFromRemote(id: string, uuid: string) {
+  let tasklist = null;
+  const apiUrl: string = `/api/tasks?uuid=${uuid}&art=${id}`; // 您的后端 API 接口地址
+
+  try {
+    const response: Response = await fetch(apiUrl, {
+      method: "GET",
+      headers: {
+        "Content-Type": "application/json",
+      },
+    });
+
+    if (response.ok) {
+      console.log("获取task数据成功:");
+      tasklist = await response.json();
+    } else {
+      console.error("未获取到task数据");
+    }
+
+    return tasklist;
+  } catch (error: any) {
+    console.error("网络请求或服务器错误:", error);
+  }
+}
+
+async function loadResource(): Promise<FillerResource> {
+  let prefix = "/assets/res/6a18f7d9957ac783bad75479";
+
+  let arr = [
+    fetch(`${prefix}/config.json`).then((res) => res.json()),
+    loadImage(`${prefix}/page.png`),
+    loadImage(`${prefix}/map.png`),
+    loadImage(`/assets/fonts/numbers_roboto_500.png`),
+  ];
+
+  let [config, page, map, numberImage, bgImage, special] =
+    await Promise.all(arr);
+
+  return new FillerResource(
+    config as AreaGroups,
+    page as HTMLImageElement,
+    map as HTMLImageElement,
+    numberImage as HTMLImageElement,
+    [],
+    special as HTMLImageElement,
+    bgImage as HTMLImageElement,
+  );
+}
+
+async function onPlay(workLayer: WorkLayer) {
+  let img = document.getElementById("poster-img") as HTMLImageElement;
+  img.style.zIndex = "0";
+  let playButton = document.getElementById("play-button") as HTMLDivElement;
+  playButton.style.zIndex = "0";
+
+  await workLayer.replay();
+
+  playButton.style.zIndex = "200";
+}

+ 29 - 0
src/utils/random.ts

@@ -0,0 +1,29 @@
+/**
+ * 伪随机数
+ */
+export class Random {
+  private _seed: number;
+  seed: number;
+
+  constructor(seed: number = Math.floor(Math.random() * 10000)) {
+    this.seed = this._seed = seed;
+  }
+
+  reset() {
+    this.seed = this._seed;
+  }
+
+  random() {
+    var x = Math.sin(this.seed) * 10000;
+    this.seed += 1;
+    return x - Math.floor(x);
+  }
+
+  nextBool() {
+    return this.random() > 0.5;
+  }
+
+  nextInt(n: number) {
+    return Math.floor(this.random() * n);
+  }
+}

+ 104 - 0
tsconfig.json

@@ -0,0 +1,104 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig to read more about this file */
+    /* Projects */
+    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
+    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
+    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+    /* Language and Environment */
+    "target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+     "lib": ["ES2017", "DOM"],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+    // "experimentalDecorators": true,                   /* Enable experimental support for legacy experimental decorators. */
+    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
+    /* Modules */
+    "module": "commonjs", /* Specify what module code is generated. */
+    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+    // "moduleResolution": "node10",                     /* Specify how TypeScript looks up a file from a given module specifier. */
+    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
+    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
+    // "allowImportingTsExtensions": true,               /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+    // "resolvePackageJsonExports": true,                /* Use the package.json 'exports' field when resolving package imports. */
+    // "resolvePackageJsonImports": true,                /* Use the package.json 'imports' field when resolving imports. */
+    // "customConditions": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+     "resolveJsonModule": true,                        /* Enable importing .json files. */
+    // "allowArbitraryExtensions": true,                 /* Enable importing files with any extension, provided a declaration file is present. */
+    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
+    /* JavaScript Support */
+    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+    // "checkJs": false,                                  /* Enable error reporting in type-checked JavaScript files. */
+    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+    /* Emit */
+    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
+    // "removeComments": true,                           /* Disable emitting comments. */
+    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
+    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
+    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+    /* Interop Constraints */
+    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+    // "verbatimModuleSyntax": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+    // "isolatedDeclarations": true,                     /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+    "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+    "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+    /* Type Checking */
+    "strict": true, /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
+    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
+    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
+    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
+    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
+    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
+    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
+    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+    /* Completeness */
+    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+    "skipLibCheck": true /* Skip type checking all .d.ts files. */
+  },
+  "typeRoots": [
+    "./typings",
+    "../node_modules/@types"
+  ]
+}

+ 3 - 0
typings/svg-mesh-3d.d.ts

@@ -0,0 +1,3 @@
+declare module "svg-mesh-3d";
+
+

+ 1 - 0
typings/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 377 - 0
typings/zingtouch.d.ts

@@ -0,0 +1,377 @@
+/**
+ * The global API interface for ZingTouch. Contains a constructor for the
+ * Region Object, and constructors for each predefined Gesture.
+ */
+declare module "zingtouch" {
+  /**
+   * Allows the user to specify a region to capture all events to feed ZingTouch
+   * into. This can be as narrow as the element itself, or as big as the document
+   * itself. The more specific an area, the better performant the overall
+   * application will perform. Contains API methods to bind/unbind specific
+   * elements to corresponding gestures. Also contains the ability to
+   * register/unregister new gestures.
+  */
+  class Region {
+    constructor(
+      element: HTMLElement,
+      capture?: boolean,
+      preventDefault?: boolean
+    );
+    bind(
+      element: HTMLElement,
+      gesture: RegionGestures,
+      handler: (params: unknown) => unknown,
+      capture?: boolean
+    ): void;
+    bind(element: HTMLElement): ZingChainable;
+    bindOnce(
+      element: HTMLElement,
+      gesture: RegionGestures,
+      handler: (params: unknown) => unknown,
+      capture?: boolean
+    ): void;
+    bindOnce(element: HTMLElement): ZingChainable;
+    unbind(element: HTMLElement, gesture?: RegionGestures): RegionGestures[];
+    register(key: string, gesture: Gesture): Gesture;
+    unregister(key: string): Gesture;
+  }
+  /**
+   * Constructor function for the Gesture class.
+   */
+  class Gesture {
+    /**
+     * The generic string type of gesture ('expand'|'pan'|'pinch'|
+     *  'rotate'|'swipe'|'tap').
+     */
+    type: string;
+    /**
+     * The unique identifier for each gesture determined at bind time by the
+     * state object. This allows for distinctions across instance variables of
+     * Gestures that are created on the fly (e.g. Tap-1, Tap-2, etc).
+     */
+    id: string | null;
+
+    constructor();
+    /**
+     * Set the type of the gesture to be called during an event
+     * @param type - The unique identifier of the gesture being created.
+     */
+    setType(type: string): void;
+    /**
+     * getType() - Returns the generic type of the gesture
+     * @returns - The type of gesture
+     */
+    getType(): string;
+    /**
+     * Set the id of the gesture to be called during an event
+     * @param id - The unique identifier of the gesture being created.
+     */
+    setId(id: string): void;
+    /**
+     * Return the id of the event. If the id does not exist, return the type.
+     */
+    getId(): string;
+    /**
+     * Updates internal properties with new ones, only if the properties exist.
+     */
+    update(object: unknown): void;
+    /**
+     * start() - Event hook for the start of a gesture
+     * @param inputs - The array of Inputs on the screen
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Default of null
+     */
+    start(inputs: unknown[], state: unknown, element: Element): null | unknown;
+    /**
+     * move() - Event hook for the move of a gesture
+     * @param inputs - The array of Inputs on the screen
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Default of null
+     */
+    move(inputs: unknown[], state: unknown, element: Element): null | unknown;
+    /**
+     * end() - Event hook for the move of a gesture
+     * @param inputs - The array of Inputs on the screen
+     * @returns - Default of null
+     */
+    end(inputs: unknown[]): null | unknown;
+    /**
+     * isValid() - Pre-checks to ensure the invariants of a gesture are satisfied.
+     * @param inputs - The array of Inputs on the screen
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - If the gesture is valid
+     */
+    isValid(inputs: unknown[], state: unknown, element: Element): boolean;
+  }
+  /**
+   * A Distance is defined as two inputs moving either together or apart.
+   */
+  class Distance extends Gesture {
+    constructor(options: {
+      /**
+       * The minimum amount in pixels the inputs must move until it is fired.
+       */
+      threshold: number;
+    });
+
+    /**
+     * Event hook for the start of a gesture. Initialized the lastEmitted
+     * gesture and stores it in the first input for reference events.
+     */
+    start(inputs: unknown[]): void;
+    /**
+     * Event hook for the move of a gesture.
+     *  Determines if the two points are moved in the expected direction relative
+     *  to the current distance and the last distance.
+     * @param inputs - The array of Inputs on the screen.
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Returns the distance in pixels between two inputs
+     */
+    move(inputs: unknown[], state: unknown, element: Element): unknown | null;
+  }
+  /**
+   * A Pan is defined as a normal movement in unknown direction on a screen.
+   * Pan gestures do not track start events and can interact with pinch and \
+   *  expand gestures.
+   */
+  class Pan extends Gesture {
+    constructor(options?: PanOptions);
+    /**
+     * Event hook for the start of a gesture. Marks each input as active,
+     * so it can invalidate unknown end events.
+     */
+    start(inputs: unknown[]): void;
+    /**
+     * move() - Event hook for the move of a gesture.
+     * Fired whenever the input length is met, and keeps a boolean flag that
+     * the gesture has fired at least once.
+     * @param inputs - The array of Inputs on the screen
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Returns the distance in pixels between the two inputs.
+     */
+    move(inputs: unknown[], state: unknown, element: Element): unknown;
+    /**
+     * end() - Event hook for the end of a gesture. If the gesture has at least
+     * fired once, then it ends on the first end event such that unknown remaining
+     * inputs will not trigger the event until all inputs have reached the
+     * touchend event. unknown touchend->touchstart events that occur before all
+     * inputs are fully off the screen should not fire.
+     * @param inputs - The array of Inputs on the screen
+     * @returns - null if the gesture is not to be emitted,
+     *  Object with information otherwise.
+     */
+    end(inputs: unknown[]): null;
+  }
+  /**
+   * A Rotate is defined as two inputs moving about a circle,
+   * maintaining a relatively equal radius.
+   */
+  class Rotate extends Gesture {
+    constructor();
+    /**
+     * move() - Event hook for the move of a gesture. Obtains the midpoint of two
+     * the two inputs and calculates the projection of the right most input along
+     * a unit circle to obtain an angle. This angle is compared to the previously
+     * calculated angle to output the change of distance, and is compared to the
+     * initial angle to output the distance from the initial angle to the current
+     * angle.
+     * @param inputs - The array of Inputs on the screen
+     * @param state - The state object of the current listener.
+     * @param element - The element associated to the binding.
+     * @returns - null if this event did not occur
+     * @returns obj.angle - The current angle along the unit circle
+     * @returns obj.distanceFromOrigin - The angular distance travelled
+     * from the initial right most point.
+     * @returns obj.distanceFromLast - The change of angle between the
+     * last position and the current position.
+     */
+    move(inputs: unknown[], state: unknown, element: Element): void;
+  }
+  /**
+   * A swipe is defined as input(s) moving in the same direction in an relatively
+   * increasing velocity and leaving the screen at some point before it drops
+   * below it's escape velocity.
+   */
+  class Swipe extends Gesture {
+    constructor(options?: SwipeOptions);
+    /**
+     * Event hook for the move of a gesture. Captures an input's x/y coordinates
+     * and the time of it's event on a stack.
+     * @param inputs - The array of Inputs on the screen.
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Swipe does not emit from a move.
+     */
+    move(inputs: unknown[], state: unknown, element: Element): null;
+    /**
+     * Determines if the input's history validates a swipe motion.
+     * Determines if it did not come to a complete stop (maxRestTime), and if it
+     * had enough of a velocity to be considered (ESCAPE_VELOCITY).
+     * @param inputs - The array of Inputs on the screen
+     * @returns - null if the gesture is not to be emitted,
+     *  Object with information otherwise.
+     */
+    end(inputs: unknown[]): null | unknown;
+  }
+  /**
+   * A Tap is defined as a touchstart to touchend event in quick succession.
+   */
+  class Tap extends Gesture {
+    constructor(options?: TapOptions);
+    /**
+     * Event hook for the start of a gesture. Keeps track of when the inputs
+     * trigger the start event.
+     * @param inputs - The array of Inputs on the screen.
+     * @returns - Tap does not trigger on a start event.
+     */
+    start(inputs: unknown[]): null;
+    /**
+     * Event hook for the move of a gesture. The Tap event reaches here if the
+     * user starts to move their input before an 'end' event is reached.
+     * @param inputs - The array of Inputs on the screen.
+     * @param state - The state object of the current region.
+     * @param element - The element associated to the binding.
+     * @returns - Tap does not trigger on a move event.
+     */
+    move(inputs: unknown[], state: unknown, element: Element): null;
+    /**
+     * Event hook for the end of a gesture.
+     * Determines if this the tap event can be fired if the delay and tolerance
+     * constraints are met. Also waits for all of the inputs to be off the screen
+     * before determining if the gesture is triggered.
+     * @param inputs - The array of Inputs on the screen.
+     * @returns - null if the gesture is not to be emitted,
+     * Object with information otherwise. Returns the interval time between start
+     * and end events.
+     */
+    end(inputs: unknown[]): null | unknown;
+  }
+  /**
+   * An Expand is defined as two inputs moving farther away from each other.
+   * This gesture does not account for unknown start/end events to allow for the
+   * event to interact with the Pan and Pinch events.
+   */
+  class Expand extends Distance {
+    constructor(options: {
+      /**
+       * The minimum amount in pixels the inputs must move until it is fired.
+       */
+      threshold: number;
+    });
+  }
+
+  /**
+   * An Pinch is defined as two inputs moving closer to each other.
+   * This gesture does not account for unknown start/end events to allow for the event
+   * to interact with the Pan and Pinch events.
+   */
+  class Pinch extends Distance {
+    constructor(options: {
+      /**
+       * The minimum amount in pixels the inputs must move until it is fired.
+       */
+      threshold: number;
+    });
+  }
+
+  type RegionGestures =
+    | "tap"
+    | Tap
+    | "pan"
+    | Pan
+    | "swipe"
+    | Swipe
+    | "rotate"
+    | Rotate
+    | "pinch"
+    | Pinch
+    | "expand"
+    | Expand
+    | Gesture;
+}
+
+interface ZingChainable {
+  tap(handler: () => void, capture?: boolean): ZingChainable;
+  swipe(handler: () => void, capture?: boolean): ZingChainable;
+  pinch(handler: () => void, capture?: boolean): ZingChainable;
+  expand(handler: () => void, capture?: boolean): ZingChainable;
+  pan(handler: () => void, capture?: boolean): ZingChainable;
+  rotate(handler: () => void, capture?: boolean): ZingChainable;
+}
+
+interface TapOptions {
+  /**
+   * The minimum amount between a touchstart and a touchend can be configured
+   * in milliseconds. The minimum delay starts to count down when the expected
+   * number of inputs are on the screen, and ends when ALL inputs are off the
+   * screen.
+   */
+  minDelay: number;
+  /**
+   * The maximum delay between a touchstart and touchend can be configured in
+   * milliseconds. The maximum delay starts to count down when the expected
+   * number of inputs are on the screen, and ends when ALL inputs are off the
+   * screen.
+   */
+  maxDelay: number;
+  /**
+   * The number of inputs to trigger a Tap can be variable,
+   * and the maximum number being a factor of the browser.
+   */
+  numInputs: number;
+  /**
+   * A move tolerance in pixels allows some slop between a user's start to end
+   * events. This allows the Tap gesture to be triggered more easily.
+   */
+  tolerance: number;
+}
+
+interface SwipeOptions {
+  /**
+   * The number of inputs to trigger a Swipe can be variable,
+   * and the maximum number being a factor of the browser.
+   */
+  numInputs: number;
+  /**
+   * The maximum resting time a point has between it's last move and
+   * current move events.
+   */
+  maxRestTime: number;
+  /**
+   * The minimum velocity the input has to be at to emit a swipe.
+   * This is useful for determining the difference between
+   * a swipe and a pan gesture.
+   */
+  escapeVelocity: number;
+  /**
+   * (EXPERIMENTAL) A value of time in milliseconds to distort between events.
+   * Browsers do not accurately measure time with the Date constructor in
+   * milliseconds, so consecutive events sometimes display the same timestamp
+   * but different x/y coordinates. This will distort a previous time
+   * in such cases by the timeDistortion's value.
+   */
+  timeDistortion?: number;
+  /**
+   * (EXPERIMENTAL) The maximum amount of move events to keep track of for a
+   * swipe. This helps give a more accurate estimate of the user's velocity.
+   */
+  maxProgressStack?: number;
+}
+
+interface PanOptions {
+  /**
+   * The number of inputs to trigger a Pan can be variable,
+   * and the maximum number being a factor of the browser.
+   */
+  numInputs?: number;
+  /**
+   * The minimum amount in pixels the pan must move until it is fired.
+   */
+  threshold?: number;
+}
+

+ 16 - 0
vite.config.js

@@ -0,0 +1,16 @@
+const { defineConfig } = require("vite");
+const { viteSingleFile } = require("vite-plugin-singlefile");
+
+module.exports = defineConfig({
+  plugins: [viteSingleFile()],
+  build: {
+    // 所有资源内联到 HTML,目标单文件广告
+    assetsInlineLimit: 100 * 1024 * 1024, // 不限大小,全部内联
+    target: "es2017",
+    rollupOptions: {
+      input: {
+        main: "./index.html",
+      },
+    },
+  },
+});

Някои файлове не бяха показани, защото твърде много файлове са промени