diff --git a/apps/app-frontend/COPYING.md b/apps/app-frontend/COPYING.md index a3dfe3b..a7dd36e 100644 --- a/apps/app-frontend/COPYING.md +++ b/apps/app-frontend/COPYING.md @@ -22,6 +22,10 @@ Item identifiers, readable names, and textures are sourced from the `minecraft-t The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft. +## About-page scene attribution + +The about-page victory illustration is the user-approved generated artwork. Its bundled scene layers preserve the approved preview without regenerating the illustration at runtime. The supplied netherite sword artwork and the unmodified `clouds.png` and `moon_phases.png` textures from Minecraft: Java Edition 1.20.6 are Minecraft resources, Copyright Mojang Studios / Microsoft. These assets are stored in `src/assets/about-scene` and do not require a network connection. + ## AI integration attribution The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt). diff --git a/apps/app-frontend/src/assets/about-scene/clouds.png b/apps/app-frontend/src/assets/about-scene/clouds.png new file mode 100644 index 0000000..cde6540 Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/clouds.png differ diff --git a/apps/app-frontend/src/assets/about-scene/moon-phases.png b/apps/app-frontend/src/assets/about-scene/moon-phases.png new file mode 100644 index 0000000..5cb5053 Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/moon-phases.png differ diff --git a/apps/app-frontend/src/assets/about-scene/netherite-sword.png b/apps/app-frontend/src/assets/about-scene/netherite-sword.png new file mode 100644 index 0000000..0d726db Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/netherite-sword.png differ diff --git a/apps/app-frontend/src/assets/about-scene/victory-clean.jpg b/apps/app-frontend/src/assets/about-scene/victory-clean.jpg new file mode 100644 index 0000000..4cd950b Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/victory-clean.jpg differ diff --git a/apps/app-frontend/src/assets/about-scene/victory-master.jpg b/apps/app-frontend/src/assets/about-scene/victory-master.jpg new file mode 100644 index 0000000..522ce12 Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/victory-master.jpg differ diff --git a/apps/app-frontend/src/assets/about-scene/victory-pose.jpg b/apps/app-frontend/src/assets/about-scene/victory-pose.jpg new file mode 100644 index 0000000..49c9dc9 Binary files /dev/null and b/apps/app-frontend/src/assets/about-scene/victory-pose.jpg differ diff --git a/apps/app-frontend/src/components/ui/AboutScene.vue b/apps/app-frontend/src/components/ui/AboutScene.vue index f90b687..7e70c88 100644 --- a/apps/app-frontend/src/components/ui/AboutScene.vue +++ b/apps/app-frontend/src/components/ui/AboutScene.vue @@ -1,318 +1,44 @@ - diff --git a/apps/app-frontend/src/components/ui/about-scene/wither-victory.ts b/apps/app-frontend/src/components/ui/about-scene/wither-victory.ts new file mode 100644 index 0000000..7e0944d --- /dev/null +++ b/apps/app-frontend/src/components/ui/about-scene/wither-victory.ts @@ -0,0 +1,950 @@ +import cloudTextureUrl from '@/assets/about-scene/clouds.png' +import moonTextureUrl from '@/assets/about-scene/moon-phases.png' +import swordTextureUrl from '@/assets/about-scene/netherite-sword.png' +import cleanUrl from '@/assets/about-scene/victory-clean.jpg' +import originalUrl from '@/assets/about-scene/victory-master.jpg' +import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg' + +type Point = [number, number] +type Vertex = [number, number, number] + +/** The approved artwork and item geometry are kept separate from the animation lifecycle. */ +export function createWitherVictoryScene(canvas: HTMLCanvasElement) { + const context = canvas.getContext('2d') + if (!context) throw new Error('Unable to initialize about-page canvas') + const ctx = context + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + const original = new Image() + const clean = new Image() + const correctedPose = new Image() + const swordTexture = new Image() + const cloudTexture = new Image() + const moonTexture = new Image() + const W = 2048, + H = W / 3, + TAU = Math.PI * 2 + let time = 0, + last = 0, + paused = false, + visible = false, + ready = false + let frame = 0, + destroyed = false + const makeLayer = (w = W, h = H) => { + const layer = document.createElement('canvas') + layer.width = Math.ceil(w) + layer.height = Math.ceil(h) + return layer + } + const base = makeLayer() + const star = makeLayer(180, 180) + const starFace = makeLayer(180, 180) + const swordFace = makeLayer(128, 128) + const grippingFingers = makeLayer(64, 80) + let cloudField: ReturnType + const buriedBlade: Point[] = [ + [1088, 526], + [1099, 505], + [1105, 491], + [1116, 490], + [1124, 483], + [1133, 485], + [1142, 478], + [1158, 477], + [1195, 480], + [1212, 534], + [1169, 567], + [1101, 555], + ] + const wound: Point[] = [ + [1104, 507], + [1116, 490], + [1136, 490], + [1140, 480], + [1156, 477], + [1178, 479], + [1173, 493], + [1157, 505], + [1152, 524], + [1137, 534], + [1126, 523], + ] + const starRows = [ + '....p....', + '...pwp...', + '..pwYwp..', + '.pwYIYwp.', + 'pwYISIYwp', + '.pwYIYwp.', + '..pwYwp..', + '...pwp...', + '....p....', + ] + const starColors: Record = { + p: '#b692d0', + w: '#f8e6cf', + Y: '#ffe3a0', + I: '#fff6da', + S: '#fffef1', + } + const shieldSurfaces: Point[][] = [ + [ + [986, 442], + [1124, 466], + [1096, 541], + [982, 532], + ], + [ + [1110, 462], + [1304, 482], + [1258, 625], + [1000, 605], + ], + [ + [1304, 482], + [1379, 504], + [1390, 614], + [1258, 625], + ], + [ + [1328, 481], + [1428, 424], + [1472, 446], + [1390, 523], + ], + [ + [1014, 603], + [1170, 626], + [1136, 683], + [869, 683], + ], + [ + [792, 481], + [950, 451], + [987, 550], + [835, 604], + ], + [ + [792, 481], + [835, 604], + [821, 625], + [780, 533], + ], + [ + [835, 604], + [987, 550], + [974, 589], + [821, 625], + ], + [ + [1165, 357], + [1301, 355], + [1247, 471], + [1108, 441], + ], + [ + [1301, 355], + [1358, 419], + [1311, 500], + [1247, 471], + ], + [ + [1483, 398], + [1626, 470], + [1558, 590], + [1404, 514], + ], + [ + [1626, 470], + [1655, 549], + [1606, 637], + [1558, 590], + ], + [ + [1404, 514], + [1558, 590], + [1606, 637], + [1449, 592], + ], + ] + // Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps. + const shieldEvents = [ + [0, 0.68, 0.48], + [0.87, 0.09, 0.82], + [1.05, 0.16, 0.27], + [1.39, 0.08, 0.61], + [2.7, 0.42, 0.2], + [3.27, 0.13, 0.65], + [4.8, 0.82, 0.42], + [5.83, 0.06, 0.82], + [6.08, 0.13, 0.29], + [6.39, 0.09, 0.7], + [8.15, 0.53, 0.26], + [9.64, 0.12, 0.69], + [9.88, 0.19, 0.34], + [10.2, 0.08, 0.73], + [11.75, 0.7, 0.45], + [12.8, 0.07, 0.87], + [13.14, 0.1, 0.24], + [15.02, 0.28, 0.5], + [15.58, 0.09, 0.86], + [15.82, 0.13, 0.31], + [17.5, 0.84, 0.22], + [18.63, 0.08, 0.68], + [18.85, 0.18, 0.39], + [20.25, 0.56, 0.53], + [21.13, 0.1, 0.86], + [21.36, 0.12, 0.35], + [22.17, 0.29, 0.18], + ] + const rand = (n: number) => { + const v = Math.sin(n * 127.1 + 311.7) * 43758.5453 + return v - Math.floor(v) + } + const sparks = Array.from({ length: 34 }, (_, i) => ({ + seed: i, + phase: rand(i), + life: 6, + size: 2 + rand(i + 9) * 5, + })) + const dust = Array.from({ length: 56 }, (_, i) => ({ + seed: i + 80, + phase: rand(i + 31), + life: 6 + rand(i + 42) * 5, + size: 4 + rand(i + 64) * 7, + })) + const armor: Point[][] = [ + [ + [655, 22], + [809, 72], + [885, 81], + [858, 132], + [773, 119], + [690, 132], + [651, 112], + ], + [ + [693, 214], + [768, 223], + [805, 385], + [727, 398], + [712, 301], + ], + [ + [627, 157], + [670, 171], + [649, 237], + [596, 221], + ], + [ + [877, 183], + [918, 210], + [899, 244], + [865, 224], + ], + ] + function path(points: Point[]) { + ctx.beginPath() + points.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))) + ctx.closePath() + } + function glow(x: number, y: number, r: number, color: string, alpha: number) { + const gradient = ctx.createRadialGradient(x, y, 0, x, y, r) + gradient.addColorStop(0, `rgba(${color},${alpha})`) + gradient.addColorStop(0.35, `rgba(${color},${alpha * 0.36})`) + gradient.addColorStop(1, `rgba(${color},0)`) + ctx.fillStyle = gradient + ctx.fillRect(x - r, y - r, r * 2, r * 2) + } + function prepare() { + // Restore the sharp master for all unaffected materials, anatomy and terrain. + const b = base.getContext('2d')! + b.imageSmoothingEnabled = false + b.drawImage(original, 0, 0, W, H) + const sky = makeLayer(), + skyCtx = sky.getContext('2d')! + skyCtx.drawImage(clean, 0, 0, W, H) + skyCtx.globalCompositeOperation = 'destination-in' + const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448) + skyFade.addColorStop(0, '#fff') + skyFade.addColorStop(1, 'transparent') + skyCtx.fillStyle = skyFade + skyCtx.fillRect(0, 0, W, 442) + b.drawImage(sky, 0, 0) + const restore = (source: HTMLImageElement, polygon: Point[]) => { + b.save() + b.beginPath() + polygon.forEach((p, i) => (i ? b.lineTo(...p) : b.moveTo(...p))) + b.closePath() + b.clip() + if (source === correctedPose) + b.drawImage(source, (583 * W) / 2172, (212 * W) / 2172, (510 * W) / 2172, (477 * W) / 2172) + else b.drawImage(source, 0, 0, W, H) + b.restore() + } + // Crisp original character; only previously corrected hands/boots use their local plate. + restore(original, [ + [687, 8], + [906, 81], + [884, 173], + [919, 183], + [923, 209], + [876, 242], + [854, 273], + [862, 314], + [918, 308], + [938, 385], + [950, 424], + [930, 480], + [817, 480], + [805, 399], + [747, 399], + [720, 491], + [627, 502], + [651, 423], + [625, 452], + [590, 437], + [574, 410], + [594, 386], + [572, 374], + [577, 341], + [574, 331], + [593, 270], + [605, 237], + [588, 237], + [607, 159], + [650, 139], + ]) + // Remove the baked angled moon locally with matching moon-free sky. + // The new vanilla moon is a separate, camera-facing layer. + const moonSky = makeLayer(280, 270), + ms = moonSky.getContext('2d')! + ms.drawImage( + clean, + 0, + 0, + (280 * clean.naturalWidth) / W, + (270 * clean.naturalHeight) / H, + 0, + 0, + 280, + 270, + ) + ms.globalCompositeOperation = 'destination-in' + ms.save() + ms.translate(140, 130) + ms.scale(1, 1.13) + const moonMask = ms.createRadialGradient(0, 0, 80, 0, 0, 123) + moonMask.addColorStop(0, '#fff') + moonMask.addColorStop(1, 'transparent') + ms.fillStyle = moonMask + ms.fillRect(-140, -135, 280, 270) + ms.restore() + b.drawImage(moonSky, 193, -20) + // Use original sharp head faces, preserving their exact silhouette without a sky border. + restore(original, [ + [1159, 355], + [1318, 352], + [1358, 421], + [1315, 498], + [1246, 474], + [1107, 443], + ]) + // Local broken-sternum and old-blade removal; the unaffected ribs stay from the master. + restore(clean, [ + [975, 339], + [1002, 334], + [1078, 369], + [1148, 456], + [1185, 469], + [1200, 481], + [1178, 520], + [1144, 562], + [1080, 570], + [1025, 548], + [1053, 502], + [1006, 476], + ]) + // Remove the two baked blue lightning remnants without replacing the whole Wither. + restore(clean, [ + [900, 595], + [948, 572], + [1000, 588], + [982, 615], + [914, 630], + [877, 625], + ]) + restore(clean, [ + [1380, 420], + [1428, 430], + [1400, 492], + [1375, 535], + [1356, 606], + [1377, 626], + [1294, 639], + [1284, 579], + [1318, 525], + [1320, 470], + ]) + restore(correctedPose, [ + [811, 377], + [951, 377], + [975, 415], + [987, 481], + [952, 496], + [813, 486], + ]) + restore(correctedPose, [ + [591, 486], + [744, 495], + [757, 650], + [550, 650], + [565, 553], + ]) + restore(correctedPose, [ + [880, 210], + [925, 218], + [951, 235], + [977, 245], + [979, 280], + [957, 302], + [932, 299], + [910, 280], + [877, 267], + ]) + try { + cloudField = makeVanillaCloudField() + } catch (error) { + console.warn('Unable to initialize about-page clouds', error) + } + // Occlude with skin pixels only: background in the palm must not erase the grip. + const fingers = grippingFingers.getContext('2d')! + fingers.drawImage( + clean, + (922 * clean.naturalWidth) / W, + (228 * clean.naturalHeight) / H, + (64 * clean.naturalWidth) / W, + (80 * clean.naturalHeight) / H, + 0, + 0, + 64, + 80, + ) + const skin = fingers.getImageData(0, 0, 64, 80) + for (let pixel = 0; pixel < skin.data.length; pixel += 4) { + const [red, green, blue] = skin.data.subarray(pixel, pixel + 3) + if (red < 90 || green < 48 || red < green * 1.04 || green < blue * 1.08) + skin.data[pixel + 3] = 0 + } + fingers.putImageData(skin, 0, 0) + // A small extruded item sprite retains sharp pixels during its vertical-axis turn. + const s = star.getContext('2d')! + starRows.forEach((row, y) => + [...row].forEach((v, x) => { + if (v !== '.') { + s.fillStyle = starColors[v] + s.fillRect(x * 20, y * 20, 20, 20) + } + }), + ) + } + function enchantedFace( + target: HTMLCanvasElement, + texture: CanvasImageSource, + t: number, + strength: number, + ) { + const c = target.getContext('2d')!, + size = target.width + c.clearRect(0, 0, size, size) + c.imageSmoothingEnabled = false + c.globalCompositeOperation = 'source-over' + c.drawImage(texture, 0, 0, size, size) + c.globalCompositeOperation = 'source-atop' + c.fillStyle = `rgba(123,63,207,${strength * 0.14})` + c.fillRect(0, 0, size, size) + // Translation of glint only: the texture coordinates and geometry never change. + const shift = ((t / 6) % 1) * size * 1.25 + for (let band = -2; band < 3; band++) { + const x = shift + band * size * 1.25 + const g = c.createLinearGradient(x - size * 0.7, 0, x + size * 0.3, size) + g.addColorStop(0, 'rgba(135,72,236,0)') + g.addColorStop(0.34, 'rgba(135,72,236,0)') + g.addColorStop(0.46, `rgba(163,99,247,${strength * 0.6})`) + g.addColorStop(0.5, `rgba(224,178,255,${strength})`) + g.addColorStop(0.55, `rgba(151,81,244,${strength * 0.5})`) + g.addColorStop(0.66, 'rgba(135,72,236,0)') + g.addColorStop(1, 'rgba(135,72,236,0)') + c.fillStyle = g + c.fillRect(0, 0, size, size) + } + c.globalCompositeOperation = 'source-over' + return target + } + function drawSword(t: number) { + const face = enchantedFace(swordFace, swordTexture, t, 0.34) + ctx.save() + // Hide only the buried sword pixels; never paint a base-image patch over the shield. + ctx.beginPath() + ctx.rect(0, 0, W, H) + buriedBlade.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))) + ctx.closePath() + ctx.clip('evenodd') + // Original 128x128 asset is rigidly rotated/scaled, with no mesh warp or repainting. + // Place the grip's actual texture center inside the corrected curled palm. + ctx.translate(938, 268) + ctx.rotate((95.5 * Math.PI) / 180) + ctx.scale(2.12, 2.12) + ctx.imageSmoothingEnabled = false + ctx.save() + ctx.translate(1.25, 1.25) + ctx.filter = 'brightness(.55)' + ctx.drawImage(swordTexture, -24, -104, 128, 128) + ctx.restore() + ctx.drawImage(face, -24, -104, 128, 128) + ctx.restore() + // The fingers occlude the handle so it remains held, rather than pasted over the hand. + ctx.drawImage(grippingFingers, 922, 228) + } + function drawStar(t: number, angle: number, x: number, y: number) { + const front = enchantedFace(starFace, star, t, 0.23) + const cosine = Math.cos(angle), + sine = Math.sin(angle), + halfDepth = 5 + ctx.save() + ctx.translate(x, y) + ctx.imageSmoothingEnabled = false + // One native item-texel layer of thickness; keep the front's pixel silhouette intact. + const edge = sine > 0 ? -1 : 1 + for (let row = 0; row < 9; row++) + for (let col = 0; col < 9; col++) { + const color = starRows[row][col] + if (color === '.') continue + const neighbor = starRows[row][col + edge] + if (neighbor && neighbor !== '.') continue + const px = ((col + (edge > 0 ? 1 : 0)) / 9 - 0.5) * 160, + py = (row / 9 - 0.5) * 160 + const a = px * cosine - halfDepth * sine, + b = px * cosine + halfDepth * sine + ctx.fillStyle = color === 'p' ? '#9070b0' : '#d9af67' + ctx.beginPath() + ctx.moveTo(a, py) + ctx.lineTo(b, py) + ctx.lineTo(b, py + 160 / 9) + ctx.lineTo(a, py + 160 / 9) + ctx.closePath() + ctx.fill() + } + ctx.translate(Math.sign(cosine) * halfDepth * sine, 0) + ctx.scale(cosine, 1) + ctx.filter = 'brightness(1.04)' + ctx.shadowColor = '#ffdc98' + ctx.shadowBlur = 9 + ctx.drawImage(front, -80, -80, 160, 160) + ctx.restore() + } + function shieldLevel(t: number) { + let level = 0 + for (const [start, duration, power] of shieldEvents) { + const p = (t - start) / duration + if (p >= 0 && p < 1) { + const envelope = Math.min(1, p * 14, (1 - p) * 10) + const sputter = 0.64 + 0.36 * rand(Math.floor(t * 19) + start * 91) + level = Math.max(level, power * envelope * sputter) + } + } + return level + } + function makeVanillaCloudField() { + // Minecraft 1.20.6's unmodified clouds.png supplies every occupied cell. + // One texel is a 12 x 12 block footprint with the vanilla 4-block thickness. + const textureCanvas = makeLayer(256, 256), + tc = textureCanvas.getContext('2d')! + tc.drawImage(cloudTexture, 0, 0) + const pixels = tc.getImageData(0, 0, 256, 256).data + const occupied = (x: number, z: number) => + pixels[(((z + 256) % 256) * 256 + ((x + 256) % 256)) * 4 + 3] > 127 + const layer = makeLayer() + const context = layer.getContext('webgl', { + alpha: true, + antialias: true, + premultipliedAlpha: true, + }) + if (!context) return + const gl = context + const vertices: number[] = [] + const quad = (a: Vertex, b: Vertex, c: Vertex, d: Vertex, shade: number) => { + for (const point of [a, b, c, a, c, d]) vertices.push(...point, shade) + } + for (let z = 8; z < 96; z++) + for (let x = 0; x < 256; x++) { + if (!occupied(x, z + 36)) continue + const l = (x - 128) * 12, + r = l + 12, + n = z * 12, + f = n + 12, + y = 84, + h = y + 4 + quad([l, y, n], [r, y, n], [r, y, f], [l, y, f], 0.7) + if (!occupied(x - 1, z + 36)) quad([l, y, n], [l, y, f], [l, h, f], [l, h, n], 0.9) + if (!occupied(x + 1, z + 36)) quad([r, y, f], [r, y, n], [r, h, n], [r, h, f], 0.9) + if (!occupied(x, z + 35)) quad([r, y, n], [l, y, n], [l, h, n], [r, h, n], 0.8) + if (!occupied(x, z + 37)) quad([l, y, f], [r, y, f], [r, h, f], [l, h, f], 0.8) + } + const shader = (kind: number, source: string) => { + const result = gl.createShader(kind) + if (!result) throw new Error('Unable to allocate cloud shader') + gl.shaderSource(result, source) + gl.compileShader(result) + if (!gl.getShaderParameter(result, gl.COMPILE_STATUS)) + throw new Error(gl.getShaderInfoLog(result) ?? 'Cloud shader compilation failed') + return result + } + const vertex = shader( + gl.VERTEX_SHADER, + ` + attribute vec4 vertex; uniform float offset; + varying float shade; varying float distance; + void main(){ + vec3 p=vertex.xyz+vec3(offset,0.0,0.0); + float focal=650.0; float horizon=350.0; + gl_Position=vec4(2.0*focal*p.x/2048.0, + (1.0-2.0*horizon/682.6667)*p.z+2.0*focal*p.y/682.6667, + 1.020202*p.z-28.282828,p.z); + shade=vertex.w; distance=p.z; + } + `, + ) + const fragment = shader( + gl.FRAGMENT_SHADER, + ` + precision mediump float; varying float shade; varying float distance; + void main(){ + float alpha=.70*(1.0-smoothstep(480.0,1150.0,distance)); + gl_FragColor=vec4(vec3(.34,.40,.50)*shade*alpha,alpha); + } + `, + ) + const program = gl.createProgram() + if (!program) throw new Error('Unable to allocate cloud program') + gl.attachShader(program, vertex) + gl.attachShader(program, fragment) + gl.linkProgram(program) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) + throw new Error(gl.getProgramInfoLog(program) ?? 'Cloud program linking failed') + gl.useProgram(program) + const buffer = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buffer) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW) + const attribute = gl.getAttribLocation(program, 'vertex') + gl.enableVertexAttribArray(attribute) + gl.vertexAttribPointer(attribute, 4, gl.FLOAT, false, 16, 0) + const offset = gl.getUniformLocation(program, 'offset') + gl.enable(gl.DEPTH_TEST) + gl.disable(gl.BLEND) + gl.clearColor(0, 0, 0, 0) + gl.viewport(0, 0, layer.width, layer.height) + return { + draw(elapsed: number) { + if (gl.isContextLost()) return + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + const travel = (elapsed * 2.4) % 3072 + for (const tile of [-3072, 0, 3072]) { + gl.uniform1f(offset, tile + travel) + gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 4) + } + return layer + }, + resize(width: number, height: number) { + layer.width = width + layer.height = height + gl.viewport(0, 0, width, height) + }, + dispose() { + gl.deleteBuffer(buffer) + gl.deleteProgram(program) + gl.deleteShader(vertex) + gl.deleteShader(fragment) + gl.getExtension('WEBGL_lose_context')?.loseContext() + }, + } + } + function drawShield(t: number) { + const level = shieldLevel(t) + canvas.dataset.shieldIntensity = level.toFixed(3) + if (level < 0.003) return + ctx.save() + ctx.globalCompositeOperation = 'screen' + // Tight fractured-cavity contour: blue armor remains on the surrounding ribs. + ctx.beginPath() + ctx.rect(0, 0, W, H) + wound.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))) + ctx.closePath() + ctx.clip('evenodd') + shieldSurfaces.forEach((quad, index) => { + const map = (u: number, v: number): Point => [ + (1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) + + v * ((1 - u) * quad[3][0] + u * quad[2][0]), + (1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) + + v * ((1 - u) * quad[3][1] + u * quad[2][1]), + ] + const alpha = level * (0.79 + 0.21 * rand(index + Math.floor(t * 7))) + path(quad) + ctx.fillStyle = `rgba(55,126,203,${alpha * 0.57})` + ctx.fill() + ctx.save() + path(quad) + ctx.clip() + for (let row = 0; row < 8; row++) + for (let col = 0; col < 8; col++) { + const grain = rand(row * 17 + col * 5 + index * 139) + if (grain < 0.56) continue + path([ + map(col / 8, row / 8), + map((col + 1) / 8, row / 8), + map((col + 1) / 8, (row + 1) / 8), + map(col / 8, (row + 1) / 8), + ]) + ctx.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})` + ctx.fill() + } + // Stepped translucent bands wrap each actual surface, like the supplied armor image. + for (let band = -1; band < 3; band++) + for (let col = 0; col < 8; col++) { + const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32 + const height = 0.034 + rand(col * 3 + band + index) * 0.022 + path([ + map(col / 8, v), + map((col + 1) / 8, v), + map((col + 1) / 8, v + height), + map(col / 8, v + height), + ]) + ctx.fillStyle = `rgba(210,229,172,${alpha * 0.75})` + ctx.fill() + } + ctx.restore() + }) + ctx.restore() + } + function drawClouds(elapsed: number) { + ctx.save() + // Clouds pass behind the character silhouette, never across the face or armor. + ctx.beginPath() + ctx.rect(0, 0, W, H) + ;[ + [687, 7], + [907, 80], + [884, 175], + [918, 182], + [921, 208], + [980, 246], + [980, 299], + [945, 306], + [899, 280], + [864, 260], + [861, 325], + [573, 325], + [594, 241], + [588, 238], + [607, 159], + [650, 139], + ].forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))) + ctx.closePath() + ctx.clip('evenodd') + const clouds = cloudField?.draw(elapsed) + if (clouds) ctx.drawImage(clouds, 0, 0, W, H) + ctx.restore() + canvas.dataset.cloudOffset = (elapsed * 2.4).toFixed(2) + } + function drawMoon() { + ctx.save() + ctx.globalCompositeOperation = 'screen' + ctx.imageSmoothingEnabled = false + ctx.filter = 'brightness(2.15)' + // Vanilla waning-crescent tile, rendered square-on with no skew or side extrusion. + // Retain its original pixel shading and native halo; black texels add no light. + ctx.drawImage(moonTexture, 96, 0, 32, 32, 45, -179, 576, 576) + ctx.restore() + } + function draw(elapsed: number) { + const t = elapsed % 24 + const ratio = canvas.width / W + ctx.setTransform(ratio, 0, 0, ratio, 0, 0) + ctx.clearRect(0, 0, W, H) + const cycle = (t * TAU) / 24 + ctx.save() + ctx.translate(W / 2, H / 2) + ctx.scale(1.008, 1.008) + ctx.translate(-W / 2 + Math.sin(cycle) * 1.6, -H / 2 + Math.sin(cycle * 2) * 0.7) + ctx.drawImage(base, 0, 0, W, H) + drawMoon() + drawClouds(elapsed) + drawShield(t) + drawSword(t) + const bob = 5.2 * Math.sin(cycle * 3) + 1.3 * Math.sin(cycle * 6 + 0.4) + const sx = 1150, + sy = 247 + bob + const pulse = 0.8 + 0.12 * Math.sin(cycle * 3 + 0.5) + 0.06 * Math.sin(cycle * 7) + ctx.save() + ctx.globalCompositeOperation = 'screen' + glow(sx, sy, 195, '255,195,102', 0.42 * pulse) + glow(sx, sy, 88, '255,232,177', 0.15 * pulse) + glow(1134, 495, 165, '255,194,101', 0.13 * pulse) + glow(790, 191, 90, '255,201,129', 0.055 * pulse) + // Narrow rays breathe with the star, instead of rotating the whole scene. + ctx.translate(sx, sy) + for (let i = 0; i < 8; i++) { + ctx.save() + ctx.rotate((i * TAU) / 8 + 0.08 * Math.sin(cycle)) + const g = ctx.createLinearGradient(0, 35, 0, 155) + g.addColorStop(0, `rgba(255,228,165,${0.13 * pulse})`) + g.addColorStop(1, 'transparent') + ctx.fillStyle = g + ctx.beginPath() + ctx.moveTo(-2, 30) + ctx.lineTo(-6, 155) + ctx.lineTo(6, 155) + ctx.lineTo(2, 30) + ctx.fill() + ctx.restore() + } + ctx.restore() + ctx.save() + ctx.globalCompositeOperation = 'screen' + for (const polygon of armor) { + ctx.save() + path(polygon) + ctx.clip() + const x = ((t / 8) % 1) * 1350 - 240 + const glint = ctx.createLinearGradient(x - 100, 0, x + 150, 450) + glint.addColorStop(0, 'transparent') + glint.addColorStop(0.4, 'rgba(160,88,255,0)') + glint.addColorStop(0.5, 'rgba(206,149,255,.28)') + glint.addColorStop(0.58, 'rgba(136,81,250,.09)') + glint.addColorStop(1, 'transparent') + ctx.fillStyle = glint + ctx.fillRect(550, 0, 650, 520) + ctx.restore() + } + ctx.restore() + // Staggered particles have invisible births/deaths, so the loop has no pop. + dust.forEach((d) => { + const q = (elapsed / d.life + d.phase) % 1, + alpha = Math.pow(Math.sin(Math.PI * q), 0.65) * 0.9 + const x = + 1145 + + (rand(d.seed) - 0.5) * 170 + + Math.sin(q * 4 + d.seed) * 24 + + q * (45 + rand(d.seed + 2) * 85) + const y = 490 - q * (180 + rand(d.seed + 9) * 130) + const size = d.size * (1 - q * 0.4) + ctx.fillStyle = `rgba(7,10,16,${alpha})` + ctx.fillRect(x, y, size, size) + if (d.seed % 3 === 0) { + ctx.fillRect(x - size * 0.65, y + size * 0.4, size * 0.7, size * 0.65) + ctx.fillRect(x + size * 0.65, y - size * 0.5, size * 0.55, size * 0.6) + } + }) + ctx.save() + ctx.globalCompositeOperation = 'screen' + sparks.forEach((d) => { + const q = (t / d.life + d.phase) % 1, + alpha = Math.pow(Math.sin(Math.PI * q), 1.7) * 0.55 + const x = 1140 + (rand(d.seed + 7) - 0.5) * 150 + Math.sin(q * 4 + d.seed) * 14 + const y = 440 - q * (130 + rand(d.seed + 19) * 120) + ctx.fillStyle = `rgba(255,207,122,${alpha})` + ctx.fillRect(x, y, d.size * 0.45, d.size * 0.45) + }) + ctx.restore() + const angle = cycle * 2 + 0.12 * Math.sin(cycle * 2) + drawStar(t, angle, sx, sy) + ctx.restore() + canvas.dataset.animationTime = elapsed.toFixed(3) + } + + function canAnimate() { + return ready && !destroyed && !paused && visible && !document.hidden && !reducedMotion.matches + } + function tick(now: number) { + frame = 0 + if (!canAnimate()) return + if (last) time += Math.min((now - last) / 1000, 0.1) + last = now + draw(time) + frame = requestAnimationFrame(tick) + } + function updatePlayback() { + if (frame) cancelAnimationFrame(frame) + frame = 0 + last = 0 + if (canAnimate()) frame = requestAnimationFrame(tick) + } + function resize() { + if (destroyed) return + const width = canvas.getBoundingClientRect().width + if (width <= 0) return + canvas.width = Math.min(W, Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1)))) + canvas.height = Math.round(canvas.width / 3) + cloudField?.resize(canvas.width, canvas.height) + if (ready) draw(time) + } + const observer = new ResizeObserver(resize) + observer.observe(canvas) + const intersection = new IntersectionObserver((entries) => { + visible = entries[0].isIntersecting + updatePlayback() + }) + intersection.observe(canvas) + document.addEventListener('visibilitychange', updatePlayback) + reducedMotion.addEventListener('change', updatePlayback) + const loaded = (img: HTMLImageElement, url: string) => + new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = () => reject(new Error('Unable to load about-page artwork')) + img.src = url + }) + const imageAssets = [original, clean, swordTexture, correctedPose, cloudTexture, moonTexture] + const loading = Promise.all([ + loaded(original, originalUrl), + loaded(clean, cleanUrl), + loaded(swordTexture, swordTextureUrl), + loaded(correctedPose, correctedPoseUrl), + loaded(cloudTexture, cloudTextureUrl), + loaded(moonTexture, moonTextureUrl), + ]) + .then(() => { + if (destroyed) return + prepare() + ready = true + resize() + updatePlayback() + }) + .finally(() => { + for (const img of imageAssets) { + img.onload = null + img.onerror = null + } + }) + return { + ready: loading, + setPaused(value: boolean) { + paused = value + updatePlayback() + }, + dispose() { + if (destroyed) return + destroyed = true + updatePlayback() + observer.disconnect() + intersection.disconnect() + document.removeEventListener('visibilitychange', updatePlayback) + reducedMotion.removeEventListener('change', updatePlayback) + cloudField?.dispose() + cloudField = undefined + for (const layer of [base, star, starFace, swordFace, grippingFingers]) { + layer.width = 1 + layer.height = 1 + } + }, + } +} diff --git a/apps/app-frontend/src/components/ui/settings/AboutSettings.vue b/apps/app-frontend/src/components/ui/settings/AboutSettings.vue index cc89b8c..74aa24d 100644 --- a/apps/app-frontend/src/components/ui/settings/AboutSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/AboutSettings.vue @@ -216,13 +216,13 @@ const messages = defineMessages({
- +