feat: 关于页接入凋灵胜利循环动画
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
将关于页顶部的旧展示替换为已确认的凋灵胜利场景,加入像素月亮、方块云、下界之星浮动旋转、附魔闪光、护盾闪烁和消散粒子。 素材随启动器本地打包,保持 3:1 构图;页面隐藏、切换或场景彩蛋覆盖时暂停渲染,退出后释放绘图资源,并兼容系统减少动态效果设置。 验证:前端构建、组件类型检查、动画暂停恢复及尺寸适配检查通过;本地启动器编译通过,并确认可执行文件已包含新场景素材和页面代码。
This commit is contained in:
@ -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).
|
||||
|
||||
BIN
apps/app-frontend/src/assets/about-scene/clouds.png
Normal file
BIN
apps/app-frontend/src/assets/about-scene/clouds.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/app-frontend/src/assets/about-scene/moon-phases.png
Normal file
BIN
apps/app-frontend/src/assets/about-scene/moon-phases.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 835 B |
BIN
apps/app-frontend/src/assets/about-scene/netherite-sword.png
Normal file
BIN
apps/app-frontend/src/assets/about-scene/netherite-sword.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-clean.jpg
Normal file
BIN
apps/app-frontend/src/assets/about-scene/victory-clean.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-master.jpg
Normal file
BIN
apps/app-frontend/src/assets/about-scene/victory-master.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
BIN
apps/app-frontend/src/assets/about-scene/victory-pose.jpg
Normal file
BIN
apps/app-frontend/src/assets/about-scene/victory-pose.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@ -1,318 +1,44 @@
|
||||
<template>
|
||||
<canvas id="about_scene" class="size-full" />
|
||||
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as THREE from 'three'
|
||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
||||
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { createWitherVictoryScene } from './about-scene/wither-victory'
|
||||
|
||||
const themeStore = useTheming()
|
||||
function isDarkMode() {
|
||||
if (themeStore.selectedTheme == 'system') {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
||||
const props = defineProps<{ paused?: boolean }>()
|
||||
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
|
||||
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
|
||||
let active = true
|
||||
|
||||
function updatePlayback() {
|
||||
scene?.setPaused(!active || Boolean(props.paused))
|
||||
}
|
||||
|
||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
||||
const tipGeometry = new THREE.SphereGeometry(2)
|
||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
||||
tipMesh.position.copy(position)
|
||||
return tipMesh
|
||||
}
|
||||
|
||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
seed: { value: Math.random() * 83 + 17 },
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define WATER_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define WATER_FRAG
|
||||
uniform float time;
|
||||
uniform float seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 randomGradient(vec2 p) {
|
||||
float n = sin(dot(p, vec2(127.1, 311.7)));
|
||||
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
|
||||
return vec2(cos(angle), sin(angle));
|
||||
}
|
||||
|
||||
float perlinNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
vec2 g1 = randomGradient(i);
|
||||
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
|
||||
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
|
||||
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
|
||||
|
||||
vec2 d1 = f;
|
||||
vec2 d2 = f - vec2(1.0, 0.0);
|
||||
vec2 d3 = f - vec2(0.0, 1.0);
|
||||
vec2 d4 = f - vec2(1.0, 1.0);
|
||||
|
||||
float v1 = dot(g1, d1);
|
||||
float v2 = dot(g2, d2);
|
||||
float v3 = dot(g3, d3);
|
||||
float v4 = dot(g4, d4);
|
||||
|
||||
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float height = 0.0;
|
||||
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
|
||||
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
|
||||
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
|
||||
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
|
||||
height = clamp(height, -1.0, 1.0);
|
||||
height = height * 0.8 + 0.6;
|
||||
|
||||
float thickness = 0.008;
|
||||
if(vUv.y < height - thickness) {
|
||||
float scalar = 1.0 - height + vUv.y;
|
||||
scalar = scalar * scalar * scalar * 0.6;
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
} else if(vUv.y > height + thickness) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
}`,
|
||||
onMounted(() => {
|
||||
if (!canvas.value) return
|
||||
scene = createWitherVictoryScene(canvas.value)
|
||||
updatePlayback()
|
||||
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
|
||||
})
|
||||
}
|
||||
|
||||
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(120, 16)
|
||||
const waterMesh = new THREE.Mesh(geometry, material)
|
||||
waterMesh.position.copy(position)
|
||||
return waterMesh
|
||||
}
|
||||
|
||||
function createCircleMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define CIRCLE_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define CIRCLE_FRAG
|
||||
varying vec2 vUv;
|
||||
uniform vec3 color;
|
||||
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
|
||||
float t = (v - inMin) / (inMax - inMin);
|
||||
return outMin + (outMax - outMin) * t;
|
||||
}
|
||||
void main() {
|
||||
float dis = distance(vUv, vec2(0.5));
|
||||
float thickness = 0.05;
|
||||
|
||||
gl_FragColor = vec4(0.0);
|
||||
if(dis <= 0.35 && dis >= 0.35 - thickness) {
|
||||
gl_FragColor = vec4(color, 0.8);
|
||||
} else {
|
||||
// emissive
|
||||
float scalar = 0.0;
|
||||
if(dis >= 0.35) {
|
||||
scalar = clamp(0.5 - dis, 0.0, 0.15);
|
||||
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
|
||||
} else {
|
||||
scalar = clamp(0.35 - dis, 0.0, 0.5);
|
||||
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
|
||||
}
|
||||
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
}
|
||||
}`,
|
||||
watch(() => props.paused, updatePlayback)
|
||||
onActivated(() => {
|
||||
active = true
|
||||
updatePlayback()
|
||||
})
|
||||
}
|
||||
|
||||
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
mesh.position.copy(position)
|
||||
return mesh
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
|
||||
if (!canvas) return console.error('No canvas')
|
||||
|
||||
let isUpdating = true
|
||||
|
||||
const canvasSize = new THREE.Vector2(
|
||||
canvas.getBoundingClientRect().width,
|
||||
canvas.getBoundingClientRect().height,
|
||||
)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
canvas,
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
updatePlayback()
|
||||
})
|
||||
renderer.setPixelRatio(devicePixelRatio)
|
||||
renderer.setSize(canvasSize.x, canvasSize.y)
|
||||
|
||||
const deltaClock = new THREE.Clock()
|
||||
const elapseClock = new THREE.Clock()
|
||||
deltaClock.start()
|
||||
elapseClock.start()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
|
||||
camera.fov *= 0.7
|
||||
camera.position.set(-10, 5, 30)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
|
||||
dirLight.position.set(-30, 30, 28)
|
||||
scene.add(dirLight)
|
||||
|
||||
scene.add(createTip(dirLight.position, 0xffff00))
|
||||
scene.add(createTip(camera.position))
|
||||
|
||||
const accentColor =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
|
||||
|
||||
const waterMaterial = createWaterMaterial()
|
||||
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 0.6 : 2.4,
|
||||
)
|
||||
// .multiplyScalar(0.6)
|
||||
// .multiplyScalar(2.4)
|
||||
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
|
||||
|
||||
// 静止的下界之星(替换原动态美西螈)
|
||||
const starTexture = new THREE.TextureLoader().load('/models/netherstar.png')
|
||||
starTexture.colorSpace = THREE.SRGBColorSpace
|
||||
const starMaterial = new THREE.SpriteMaterial({ map: starTexture, transparent: true })
|
||||
const starSprite = new THREE.Sprite(starMaterial)
|
||||
starSprite.scale.set(8, 8, 1)
|
||||
starSprite.position.set(0, -2.5, 0)
|
||||
scene.add(starSprite)
|
||||
|
||||
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
|
||||
|
||||
const circleMaterial = createCircleMaterial()
|
||||
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 1.2 : 3,
|
||||
)
|
||||
// .multiplyScalar(1.2)
|
||||
// .multiplyScalar(3)
|
||||
|
||||
let circleMeshList: THREE.Mesh[] = []
|
||||
let nextCircleCreateTime = 0.0
|
||||
function updateCircle(deltaTime: number, elapsedTime: number) {
|
||||
circleMeshList = circleMeshList.filter((m) => {
|
||||
m.position.y += deltaTime * 2.0
|
||||
if (m.position.y >= 32) {
|
||||
scene.remove(m)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (elapsedTime >= nextCircleCreateTime) {
|
||||
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
|
||||
const circle = createCircle(
|
||||
circleMaterial,
|
||||
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
|
||||
)
|
||||
scene.add(circle)
|
||||
circleMeshList.push(circle)
|
||||
}
|
||||
}
|
||||
|
||||
function animate(_time: number) {
|
||||
if (isUpdating === false) return
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
const deltaTime = deltaClock.getDelta()
|
||||
const elapsedTime = elapseClock.getElapsedTime()
|
||||
|
||||
updateGLTF(deltaTime, elapsedTime)
|
||||
waterMaterial.uniforms.time.value = elapsedTime
|
||||
|
||||
updateCircle(deltaTime, elapsedTime)
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate(Date.now())
|
||||
|
||||
const originCameraPosition = camera.position.clone()
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
|
||||
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
|
||||
const newPosition = new THREE.Vector3(
|
||||
originCameraPosition.x + mouseXOffsetRatio,
|
||||
originCameraPosition.y + mouseYOffsetRatio * 0.5,
|
||||
originCameraPosition.z,
|
||||
)
|
||||
camera.position.copy(newPosition)
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
if (!isUpdating) return
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer.setSize(w, h)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
addEventListener('mousemove', onMouseMove)
|
||||
onScopeDispose(() => {
|
||||
isUpdating = false
|
||||
removeEventListener('mousemove', onMouseMove)
|
||||
resizeObserver.disconnect()
|
||||
deltaClock.stop()
|
||||
elapseClock.stop()
|
||||
renderer.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(main)
|
||||
onScopeDispose(() => scene?.dispose())
|
||||
</script>
|
||||
<style>
|
||||
#about_scene {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
||||
#00000000 40%
|
||||
);
|
||||
|
||||
<style scoped>
|
||||
.about-scene {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c1729;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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<typeof makeVanillaCloudField>
|
||||
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<string, string> = {
|
||||
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<void>((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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -216,13 +216,13 @@ const messages = defineMessages({
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
ref="experienceHost"
|
||||
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
|
||||
class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
|
||||
style="
|
||||
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
||||
"
|
||||
>
|
||||
<AboutScene />
|
||||
<AboutScene :paused="Boolean(activeMemberExperience)" />
|
||||
<component
|
||||
:is="activeMemberExperience?.component"
|
||||
v-if="activeMemberExperience"
|
||||
|
||||
Reference in New Issue
Block a user