index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { BgLayer, BgType } from "../base/BgLayer";
  2. import { BorderLayer } from "../base/BorderLayer";
  3. import { BoxLayer } from "../base/BoxLayer";
  4. import { DebugLayer } from "../base/DebugLayer";
  5. import { FrameLayer } from "../base/FrameLayer";
  6. import { TouchTracker } from "../base/Gesture";
  7. import { ImageShaders } from "../base/ImageShaders";
  8. import { Padding, Scene } from "../base/Scene";
  9. import { TextureLayer } from "../base/TextureLayer";
  10. import { loadImage } from "../base/utils";
  11. import AudioPlayer, { AudioType } from "./Audio";
  12. import { AreaGroup, AreaGroups, Color, Settings } from "./common";
  13. import Explosion from "./explosion";
  14. import {
  15. FillerConfig,
  16. FillerData,
  17. FillerResource,
  18. FillerCallback,
  19. } from "./FillerData";
  20. import { FillerScene } from "./FillerScene";
  21. import { HintLayer } from "./HintLayer";
  22. import { LineArtLayer } from "./LineArtLayer";
  23. import { NumberLayer } from "./NumberLayer";
  24. import { WorkLayer } from "./WorkLayer";
  25. import { LoadingController } from "./LoadingController";
  26. import { FingerHint } from "./FingerHint";
  27. import JSConfetti from "js-confetti";
  28. import { initCta, ctaHighlight } from "./cta";
  29. import { initAdPlatform } from "./mraid";
  30. // 静态导入资源,Vite 构建时会将它们转为 data URI 内联进 HTML
  31. import configRaw from "/assets/res/6a18f7d9957ac783bad75479/config.json?raw";
  32. import pageUrl from "/assets/res/6a18f7d9957ac783bad75479/page.png?url";
  33. import mapUrl from "/assets/res/6a18f7d9957ac783bad75479/map.png?url";
  34. import specialUrl from "/assets/res/6a18f7d9957ac783bad75479/special.jpeg?url";
  35. import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";
  36. import fingerUrl from "/assets/img/finger.png?url";
  37. import logoUrl from "/assets/img/logo.png?url";
  38. import logoTxtUrl from "/assets/img/logo-txt.png?url";
  39. import coloringPagesUrl from "/assets/img/coloring-pages.png?url";
  40. import slogonUrl from "/assets/img/slogon.png?url";
  41. document.body.onload = function () {
  42. initCta();
  43. init();
  44. };
  45. async function test() {
  46. let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
  47. canvas.width = canvas.clientWidth * window.devicePixelRatio;
  48. canvas.height = canvas.clientHeight * window.devicePixelRatio;
  49. let resource = await loadResource();
  50. let ctx = canvas.getContext("2d")!;
  51. ctx.fillStyle = "red";
  52. ctx.fillRect(0, 0, canvas.width, canvas.height);
  53. ctx.drawImage(resource.page, 0, 0);
  54. }
  55. async function init() {
  56. const loadingController = new LoadingController({
  57. minDisplayTime: 1000, // 最小显示时间1秒
  58. fadeDuration: 300, // 淡出动画300ms
  59. });
  60. loadingController.show();
  61. let canvas = document.querySelector("#canvas") as HTMLCanvasElement;
  62. let gl = canvas.getContext("webgl2", {
  63. premultipliedAlpha: false,
  64. }) as WebGL2RenderingContext;
  65. let pixelRatio = window.devicePixelRatio;
  66. /** 根据 game-area 的实际渲染尺寸同步 canvas 像素尺寸 */
  67. function syncCanvasSize() {
  68. const gameArea = document.getElementById("game-area")!;
  69. const w = Math.round(gameArea.clientWidth * pixelRatio);
  70. const h = Math.round(gameArea.clientHeight * pixelRatio);
  71. if (canvas.width !== w || canvas.height !== h) {
  72. canvas.width = w;
  73. canvas.height = h;
  74. scene?.updateViewport();
  75. }
  76. }
  77. canvas.width = canvas.clientWidth * pixelRatio;
  78. canvas.height = canvas.clientHeight * pixelRatio;
  79. let scene = new FillerScene(gl, pixelRatio);
  80. // 确保 canvas 像素尺寸与实际 flex 布局一致(body.onload 时 dvh 可能还未最终结算)
  81. syncCanvasSize();
  82. let audio = new AudioPlayer();
  83. // 加载设置项,学个新语法 ...
  84. let settings: Settings = {
  85. hintStyle: "default",
  86. sound: true,
  87. vibrate: true,
  88. autoNext: true,
  89. };
  90. window.addEventListener("resize", () => syncCanvasSize());
  91. let resource = await loadResource();
  92. loadingController.hide();
  93. initAdPlatform();
  94. // 设置固定顶栏 logo(图标 + 文字)
  95. (document.getElementById("app-logo") as HTMLImageElement).src = logoUrl;
  96. (document.getElementById("app-logo-txt") as HTMLImageElement).src =
  97. logoTxtUrl;
  98. // 预加载 promo 图片,避免动画播放时图片刚加载导致布局跳变
  99. (document.getElementById("promo-coloring") as HTMLImageElement).src =
  100. coloringPagesUrl;
  101. (document.getElementById("promo-slogon") as HTMLImageElement).src = slogonUrl;
  102. let taskList: number[] = [];
  103. // fingerHint 先声明,init 末尾赋值后回调中就能使用
  104. let fingerHint: FingerHint | null = null;
  105. let fillerData = new FillerData(
  106. new FillerConfig(settings),
  107. resource,
  108. gl,
  109. taskList,
  110. {
  111. onFillFailed() {
  112. console.log("填充失败");
  113. fingerHint?.onUserInteraction();
  114. },
  115. onFillSuccess() {
  116. console.log("填充成功");
  117. fingerHint?.onUserInteraction();
  118. if (fillerData.config.settings.vibrate) {
  119. vibrate();
  120. }
  121. cssAdjustProgress(fillerData.data.coloredPercent);
  122. cssSetColorProgress(
  123. fillerData.currentGroupIndex,
  124. fillerData.currentGroup!.progressPercent,
  125. );
  126. if (fillerData.currentGroup?.isAllColored) {
  127. if (fillerData.config.settings.sound) {
  128. audio.playAudio(AudioType.ColorDone);
  129. }
  130. cssColorDone(fillerData);
  131. }
  132. },
  133. onSwitchGroup() {
  134. let currentGroupIndex = fillerData.currentGroupIndex;
  135. console.log(`切换到下一个group ${currentGroupIndex}`);
  136. cssSelectColor(currentGroupIndex);
  137. cssAdjustScroll(fillerData);
  138. },
  139. onFinish() {
  140. fingerHint?.stop();
  141. cssOnFinish(scene, workLayer, hintLayer, audio);
  142. },
  143. },
  144. );
  145. scene.fillerData = fillerData;
  146. console.log("resource", resource);
  147. // canvas 区域与所有 UI 完全分离,只保留极小视觉边距
  148. scene.setContentPadding(new Padding(20, 20, 20, 20));
  149. //let page = await loadImage('/webgl/page.png')
  150. //let page = await loadImage('/assets/resources/friend/page_gray.png')
  151. let width = fillerData.width;
  152. let height = fillerData.height;
  153. scene.setContentSize(width, height);
  154. // // 最底层:透明背景,canvas 透出 body 渐变色,与整体 UI 融为一体
  155. // scene.addLayer(new BackgroundLayer(scene, scene.width, scene.height, [0, 0, 0, 0]));
  156. if (resource.bg) {
  157. scene.addLayer(
  158. new BgLayer(scene, resource.bg, scene.width, scene.height, BgType.Repeat),
  159. );
  160. }
  161. scene.addLayer(new BoxLayer(scene, 0, 0, width, height));
  162. let hintLayer = new HintLayer(scene, fillerData);
  163. scene.addLayer(hintLayer);
  164. scene.addLayer(new NumberLayer(scene, resource.numberImage, fillerData, 1));
  165. let workLayer = new WorkLayer(scene, fillerData);
  166. scene.addLayer(workLayer);
  167. //scene.addLayer(new TextureLayer(scene, workLayer.mask.texture, fillerData.width, fillerData.height, fillerData.width, fillerData.height))
  168. scene.addLayer(new LineArtLayer(scene, resource.page, width, height));
  169. scene.addLayer(
  170. new BorderLayer(
  171. scene,
  172. 0,
  173. 0,
  174. fillerData.width,
  175. fillerData.height,
  176. 0xff222222,
  177. ),
  178. );
  179. // let mask = new Mask(gl, fillerData)
  180. // resource.config[0].areas.forEach(area => mask.addArea(area))
  181. // mask.flush()
  182. //scene.addTestLayer(new TextureLayer(scene, mask.texture, fillerData.width, fillerData.height, width, height, ImageShaders.CommonGaussianBlurCut))
  183. //scene.addTestLayer(new TextureLayer(scene, mask.texture, fillerData.width, fillerData.height, width, height, ))
  184. //scene.addLayer(new TextureLayer(scene, hintLayer.mask.texture, fillerData.width, fillerData.height, width, height, ImageShaders.CommonGaussianBlur ))
  185. //scene.addLayer(new DebugLayer(scene))
  186. //scene.addLayer(new ImageLayer(scene, resource.page, width, height, gl.LINEAR, ImageShaders.CommonGaussianBlur))
  187. //scene.addLayer(new TextureLayer(scene, fillerData.mapTexure, width, height, width, height))
  188. createButtons(fillerData);
  189. cssAdjustProgress(fillerData.data.coloredPercent);
  190. workLayer.initTask();
  191. if (fillerData.data.coloredPercent >= 100) {
  192. cssOnFinish(scene, workLayer, hintLayer, null);
  193. } else {
  194. // 创建手指提示,全图未完成时启动引导
  195. fingerHint = new FingerHint(scene, fillerData, fingerUrl);
  196. fingerHint.start();
  197. }
  198. }
  199. async function loadResource(): Promise<FillerResource> {
  200. const config = JSON.parse(configRaw) as AreaGroups;
  201. const [page, map, special, numberImage] = await Promise.all([
  202. loadImage(pageUrl),
  203. loadImage(mapUrl),
  204. loadImage(specialUrl),
  205. loadImage(numberFontUrl),
  206. ]);
  207. return new FillerResource(
  208. config,
  209. page as HTMLImageElement,
  210. map as HTMLImageElement,
  211. numberImage as HTMLImageElement,
  212. [],
  213. special as HTMLImageElement,
  214. );
  215. }
  216. function createButtons(fillerData: FillerData) {
  217. let data = fillerData.data.areaGroups;
  218. let htmlUndoned = [];
  219. let htmlDone = [];
  220. let html = [];
  221. let areaGroup: AreaGroup;
  222. let color: Color;
  223. for (var i = 0; i < data.length; i++) {
  224. areaGroup = data[i];
  225. color = new Color(areaGroup.color);
  226. if (areaGroup.isAllColored) {
  227. htmlDone.push(
  228. `<div id="color-btn-container-${i}" class="color-btn-container" onclick="selectColor(this, event, ${i})">`,
  229. );
  230. htmlDone.push(
  231. `<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>`,
  232. );
  233. htmlDone.push(
  234. `<div id="color-btn-${i}" class="color-btn" style="background-color:${color.css()}; color: ${color.gray < 192 ? "white" : "black"}">`,
  235. );
  236. htmlDone.push(`✓`);
  237. htmlDone.push(`</div>`);
  238. htmlDone.push(`</div>`);
  239. } else {
  240. if (
  241. areaGroup === fillerData.currentGroup ||
  242. areaGroup.progressPercent > 0.0
  243. ) {
  244. } // 需要展示circle progress
  245. htmlUndoned.push(
  246. `<div id="color-btn-container-${i}" class="color-btn-container" onclick="selectColor(this, event, ${i})">`,
  247. );
  248. htmlUndoned.push(
  249. `<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>`,
  250. );
  251. htmlUndoned.push(
  252. `<div id="color-btn-${i}" class="color-btn" style="background-color:${color.css()}; color: ${color.gray < 192 ? "white" : "black"}">`,
  253. );
  254. htmlUndoned.push(`${i + 1}`);
  255. htmlUndoned.push(`</div>`);
  256. htmlUndoned.push(`</div>`);
  257. }
  258. }
  259. html = htmlUndoned.concat(htmlDone);
  260. let c = document.querySelector("#color-btns");
  261. if (c != null) {
  262. c.innerHTML = html.join("");
  263. }
  264. // 选中当前color
  265. cssSelectColor(fillerData.currentGroupIndex);
  266. // 调整每个color item的进度
  267. for (var i = 0; i < data.length; i++) {
  268. let areaGroup = data[i];
  269. let percent = areaGroup.progressPercent;
  270. cssInitColorProgress(i, percent);
  271. }
  272. (window as any).selectColor = function (
  273. el: HTMLElement,
  274. event: MouseEvent,
  275. i: number,
  276. ) {
  277. console.log("select", el, event, i);
  278. fillerData.setCurrentGroup(i);
  279. document.querySelectorAll(".color-btn-container").forEach((item) => {
  280. item.classList.remove("color-btn-container-selected");
  281. });
  282. el.classList.add("color-btn-container-selected");
  283. };
  284. }
  285. /**
  286. * 调整整体完成进度条
  287. */
  288. function cssAdjustProgress(percent: number) {
  289. let progressElem = document.getElementById("progress") as HTMLDivElement;
  290. let percentElem = document.getElementById("percent") as HTMLDivElement;
  291. percentElem.innerText = `${percent}%`;
  292. progressElem.style.width = `${percent}%`;
  293. }
  294. // 选中某个color, 自动切换到下一个颜色的时候触发调用
  295. function cssSelectColor(i: number) {
  296. document.querySelectorAll(".color-btn-container").forEach((item) => {
  297. item.classList.remove("color-btn-container-selected");
  298. });
  299. let elem = document.getElementById(`color-btn-container-${i}`);
  300. elem?.classList.add("color-btn-container-selected");
  301. }
  302. // 设置color button的进度,即areagroup的进度
  303. function cssSetColorProgress(i: number, percent: number) {
  304. const container = document.getElementById(`color-btn-container-${i}`)!;
  305. const progressCircle = document.getElementById(
  306. `color-btn-progress-ring-value-${i}`,
  307. )!;
  308. const containerWidth = container.clientWidth;
  309. const radius = containerWidth * 0.45;
  310. const circumference = 2 * Math.PI * radius;
  311. const offset = circumference - (percent * circumference) / 100;
  312. progressCircle.style.strokeDashoffset = offset.toString();
  313. if (percent >= 100) {
  314. // 如果此color已经完成,文本变为✓, 同时移动到队列后面
  315. const item = document.getElementById(`color-btn-${i}`)!;
  316. item.innerText = "✓";
  317. }
  318. }
  319. // 初始化color button进度
  320. function cssInitColorProgress(i: number, percent: number) {
  321. const container = document.getElementById(`color-btn-container-${i}`)!;
  322. const progressCircle = document.getElementById(
  323. `color-btn-progress-ring-value-${i}`,
  324. )!;
  325. // 通过容器尺寸计算 SVG 半径
  326. const containerWidth = container.clientWidth;
  327. const radius = containerWidth * 0.45; // 对应 r="45%"
  328. // 计算周长并设置属性
  329. const circumference = 2 * Math.PI * radius;
  330. const offset = circumference - (percent * circumference) / 100;
  331. progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;
  332. progressCircle.style.strokeDashoffset = offset.toString();
  333. }
  334. // 调整滚动条
  335. function cssAdjustScroll(fd: FillerData) {
  336. let wrapper = document.getElementById("color-btns")!;
  337. let count =
  338. document.body.clientWidth /
  339. (wrapper.scrollWidth / fd.data.areaGroups.length); // 视窗内显示了多少个元素
  340. let width = document.body.clientWidth / count; // 每个元素占用的宽度
  341. // 计算位置
  342. let scrollpos =
  343. (wrapper.scrollWidth / fd.data.areaGroups.length) *
  344. (fd.currentGroupIndex - fd.doneBeforeCount) -
  345. (count * width) / 2 +
  346. width / 2;
  347. scrollpos = scrollpos < 0 ? 0 : scrollpos;
  348. // wrapper.scrollLeft = scrollpos ; // 不要直接设置scrollLeft,太突然没效果
  349. try {
  350. wrapper.scroll({ left: scrollpos, top: 0, behavior: "smooth" }); // 发现有些低端机不支持
  351. } catch (e) {
  352. console.log(e);
  353. wrapper.scrollLeft = scrollpos;
  354. }
  355. }
  356. /**
  357. * 数字item爆破动画
  358. */
  359. function cssExplosionAnimation(
  360. numElem: HTMLElement,
  361. color: string,
  362. onfinish: Function,
  363. ) {
  364. let aniCanvas = document.createElement("canvas");
  365. aniCanvas.className = "finish-ani-canvas";
  366. let coords = numElem.getBoundingClientRect();
  367. aniCanvas.style.left = `${coords.left - 50}px`;
  368. aniCanvas.style.top = `${coords.top - 50}px`;
  369. aniCanvas.style.width = `${coords.width + 100}px`;
  370. aniCanvas.style.height = `${coords.height + 100}px`;
  371. document.body.append(aniCanvas);
  372. let explosion = new Explosion(aniCanvas, color, 50, () => {
  373. onfinish();
  374. aniCanvas.remove();
  375. });
  376. explosion.explode();
  377. }
  378. // 某个颜色填充完毕
  379. function cssColorDone(fillerData: FillerData) {
  380. let i = fillerData.currentGroupIndex;
  381. let color = new Color(fillerData.currentGroup!.color).css();
  382. let wrapper = document.getElementById("color-btns")!;
  383. let buttonWrap = document.getElementById(`color-btn-container-${i}`)!;
  384. setTimeout(() => {
  385. //等环形进度条走完,开始爆破动画
  386. buttonWrap.style.visibility = "hidden";
  387. cssExplosionAnimation(buttonWrap, color, () => {
  388. // 爆破动画结束后,将元素移至末尾
  389. wrapper.lastElementChild!.after(buttonWrap);
  390. buttonWrap.style.visibility = "visible";
  391. });
  392. }, 300);
  393. }
  394. // 全部填完
  395. function cssOnFinish(
  396. scene: FillerScene,
  397. workLayer: WorkLayer,
  398. hintLayer: HintLayer,
  399. audio: AudioPlayer | null,
  400. ) {
  401. // 立即将 canvas 底色改为透明,让 body 渐变色从绘图区域透出,避免 replay/消失动画中出现局部白色块
  402. scene.setClearTransparent();
  403. // 隐藏toolbar
  404. const toolbarBottom = document.getElementById(
  405. "toolbar-bottom",
  406. ) as HTMLDivElement;
  407. // 爆破动画结束后隐藏元素
  408. setTimeout(() => {
  409. if (scene.fillerData!.config.settings.sound) {
  410. audio?.playAudio(AudioType.AllDone);
  411. }
  412. scene.resetToResult();
  413. toolbarBottom.classList.add("hidden-toolbar-bottom"); // 添加 hidden 类,触发动画
  414. // 动画结束后隐藏元素
  415. setTimeout(() => {
  416. // toolbarBottom.style.display = 'none';
  417. scene.addLayer(
  418. new FrameLayer(
  419. scene,
  420. -4,
  421. -4,
  422. scene.fillerData!.width + 8,
  423. scene.fillerData!.height + 8,
  424. 0xff9bceed,
  425. 16,
  426. ),
  427. );
  428. hintLayer.enabled = false; // replay 期间隐藏 hint 覆盖层
  429. scene.invalidate();
  430. workLayer.replay().then(() => showPromoScreen());
  431. }, 500); // 过渡动画持续 0.5 秒
  432. }, 300); // 粒子爆破动画持续 0.3 秒
  433. setTimeout(() => {
  434. confetti();
  435. }, 300);
  436. }
  437. function showPromoScreen() {
  438. const canvas = document.getElementById("canvas") as HTMLCanvasElement;
  439. const promoScreen = document.getElementById("promo-screen") as HTMLDivElement;
  440. const promoColoring = document.getElementById(
  441. "promo-coloring",
  442. ) as HTMLImageElement;
  443. const promoSlogon = document.getElementById(
  444. "promo-slogon",
  445. ) as HTMLImageElement;
  446. // 预设图片资源(已在 init 阶段预加载,此处保留赋值保证兼容性)
  447. promoColoring.src = coloringPagesUrl;
  448. promoSlogon.src = slogonUrl;
  449. // 1. 将画布缩小至消失
  450. canvas.classList.add("canvas-shrink-out");
  451. // 2. canvas 过渡完成后(~600ms)展示宣传界面 + logo 淡入
  452. setTimeout(() => {
  453. canvas.style.display = "none";
  454. promoScreen.classList.add("visible");
  455. // 双 rAF:确保浏览器渲染初始态后再触发 transition
  456. requestAnimationFrame(() => {
  457. requestAnimationFrame(() => {
  458. promoColoring.classList.add("animate-in");
  459. promoSlogon.classList.add("animate-in");
  460. // 宣传界面出现后强化 CTA 按钮
  461. ctaHighlight();
  462. });
  463. });
  464. }, 620);
  465. }
  466. function onHint(audio: AudioPlayer, scene: FillerScene) {
  467. if (scene.fillerData?.config.settings.sound) {
  468. audio.playAudio(AudioType.Hint);
  469. }
  470. scene.hint();
  471. }
  472. function goback() {
  473. if (document.referrer.includes(window.location.hostname)) {
  474. window.history.back();
  475. } else {
  476. window.location.href = "/"; // 无来源时跳首页
  477. }
  478. }
  479. function vibrate() {
  480. // 判断浏览器是否支持震动
  481. var supportVibrate = "vibrate" in navigator;
  482. console.log("supportVibrate: " + supportVibrate);
  483. if (supportVibrate) {
  484. navigator.vibrate(30);
  485. }
  486. }
  487. function showToast(message: string) {
  488. const toast = document.getElementById("toast");
  489. if (toast) {
  490. toast.textContent = message;
  491. toast.classList.remove("toast-hidden");
  492. setTimeout(() => toast.classList.add("toast-hidden"), 2500);
  493. }
  494. }
  495. // 撒花动画,使用js-confetti库
  496. function confetti() {
  497. const jsConfetti = new JSConfetti();
  498. jsConfetti.addConfetti();
  499. }
  500. function onActionSheetClick(evt: Event) {
  501. evt.stopPropagation();
  502. }