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 { adPlatform } from "./ad-platform/current"; // 静态导入资源,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"; window.addEventListener("load", () => { document.body.dataset.adPlatform = adPlatform.platform; adPlatform.init(); initCta(adPlatform); 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 }); if (adPlatform.shouldUseCustomLoading()) { 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); // 确保 canvas 像素尺寸与实际 flex 布局一致(body.onload 时 dvh 可能还未最终结算) syncCanvasSize(); let audio = new AudioPlayer(); // 加载设置项,学个新语法 ... let settings: Settings = { hintStyle: "default", sound: true, vibrate: true, autoNext: true, }; window.addEventListener("resize", () => syncCanvasSize()); let resource = await loadResource(); if (adPlatform.shouldUseCustomLoading()) { loadingController.hide(); } adPlatform.onResourcesLoaded(); // 设置固定顶栏 logo(图标 + 文字) (document.getElementById("app-logo") as HTMLImageElement).src = logoUrl; (document.getElementById("app-logo-txt") as HTMLImageElement).src = logoTxtUrl; // 预加载 promo 图片,避免动画播放时图片刚加载导致布局跳变 (document.getElementById("promo-coloring") as HTMLImageElement).src = coloringPagesUrl; (document.getElementById("promo-slogon") as HTMLImageElement).src = slogonUrl; let taskList: number[] = []; // fingerHint 先声明,init 末尾赋值后回调中就能使用 let fingerHint: FingerHint | null = null; let fillerData = new FillerData( new FillerConfig(settings), resource, gl, taskList, { onFillFailed() { adPlatform.onGameStart(); console.log("填充失败"); fingerHint?.onUserInteraction(); }, onFillSuccess() { adPlatform.onGameStart(); 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(); adPlatform.onGameEnd(); cssOnFinish(scene, workLayer, hintLayer, numberLayer, audio); }, }, ); scene.fillerData = fillerData; console.log("resource", resource); // canvas 区域与所有 UI 完全分离,只保留极小视觉边距 scene.setContentPadding(new Padding(20, 20, 20, 20)); //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); // // 最底层:透明背景,canvas 透出 body 渐变色,与整体 UI 融为一体 // scene.addLayer(new BackgroundLayer(scene, scene.width, scene.height, [0, 0, 0, 0])); 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 hintLayer = new HintLayer(scene, fillerData); scene.addLayer(hintLayer); let numberLayer = new NumberLayer(scene, resource.numberImage, fillerData, 1); scene.addLayer(numberLayer); 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) { adPlatform.onGameEnd(); cssOnFinish(scene, workLayer, hintLayer, numberLayer, null); } else { // 创建手指提示,全图未完成时启动引导 fingerHint = new FingerHint(scene, fillerData, fingerUrl); fingerHint.start(); } } async function loadResource(): Promise { 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( `
`, ); htmlDone.push( ` `, ); htmlDone.push( `
`, ); htmlDone.push(`✓`); htmlDone.push(`
`); htmlDone.push(`
`); } else { if ( areaGroup === fillerData.currentGroup || areaGroup.progressPercent > 0.0 ) { } // 需要展示circle progress htmlUndoned.push( `
`, ); htmlUndoned.push( ` `, ); htmlUndoned.push( `
`, ); htmlUndoned.push(`${i + 1}`); htmlUndoned.push(`
`); htmlUndoned.push(`
`); } } 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的进度 // SVG viewBox 常量,所有strokeDasharray/offset计算均在此坐标系内 const COLOR_BTN_VIEWBOX = 48; const COLOR_BTN_RING_RATIO = 0.45; const COLOR_BTN_RADIUS = COLOR_BTN_VIEWBOX * COLOR_BTN_RING_RATIO; // 21.6 const COLOR_BTN_CIRCUMFERENCE = 2 * Math.PI * COLOR_BTN_RADIUS; function cssSetColorProgress(i: number, percent: number) { const progressCircle = document.getElementById( `color-btn-progress-ring-value-${i}`, )!; const offset = COLOR_BTN_CIRCUMFERENCE - (percent * COLOR_BTN_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 progressCircle = document.getElementById( `color-btn-progress-ring-value-${i}`, )!; const offset = COLOR_BTN_CIRCUMFERENCE - (percent * COLOR_BTN_CIRCUMFERENCE) / 100; progressCircle.style.strokeDasharray = `${COLOR_BTN_CIRCUMFERENCE} ${COLOR_BTN_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, hintLayer: HintLayer, numberLayer: NumberLayer, audio: AudioPlayer | null, ) { // 立即将 canvas 底色改为透明,让 body 渐变色从绘图区域透出,避免 replay/消失动画中出现局部白色块 scene.setClearTransparent(); // 隐藏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, ), ); hintLayer.enabled = false; // replay 期间隐藏 hint 覆盖层 numberLayer.enabled = false; // replay 期间隐藏 number 覆盖层 scene.invalidate(); 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; // 预设图片资源(已在 init 阶段预加载,此处保留赋值保证兼容性) 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(); } function onActionSheetClick(evt: Event) { evt.stopPropagation(); }