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.
|
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
|
## 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).
|
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>
|
<template>
|
||||||
<canvas id="about_scene" class="size-full" />
|
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as THREE from 'three'
|
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
|
||||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
|
||||||
|
|
||||||
import { useTheming } from '@/store/theme'
|
import { createWitherVictoryScene } from './about-scene/wither-victory'
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const props = defineProps<{ paused?: boolean }>()
|
||||||
function isDarkMode() {
|
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
|
||||||
if (themeStore.selectedTheme == 'system') {
|
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
|
||||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
let active = true
|
||||||
}
|
|
||||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
function updatePlayback() {
|
||||||
|
scene?.setPaused(!active || Boolean(props.paused))
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
onMounted(() => {
|
||||||
const tipGeometry = new THREE.SphereGeometry(2)
|
if (!canvas.value) return
|
||||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
scene = createWitherVictoryScene(canvas.value)
|
||||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
updatePlayback()
|
||||||
tipMesh.position.copy(position)
|
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
|
||||||
return tipMesh
|
})
|
||||||
}
|
watch(() => props.paused, updatePlayback)
|
||||||
|
onActivated(() => {
|
||||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
active = true
|
||||||
return new THREE.ShaderMaterial({
|
updatePlayback()
|
||||||
uniforms: {
|
})
|
||||||
time: { value: 0 },
|
onDeactivated(() => {
|
||||||
seed: { value: Math.random() * 83 + 17 },
|
active = false
|
||||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
updatePlayback()
|
||||||
},
|
})
|
||||||
transparent: true,
|
onScopeDispose(() => scene?.dispose())
|
||||||
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);
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
#about_scene {
|
<style scoped>
|
||||||
background: linear-gradient(
|
.about-scene {
|
||||||
to bottom,
|
display: block;
|
||||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
width: 100%;
|
||||||
#00000000 40%
|
height: 100%;
|
||||||
);
|
background: #0c1729;
|
||||||
}
|
}
|
||||||
</style>
|
</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 class="flex flex-col items-center gap-4">
|
||||||
<div
|
<div
|
||||||
ref="experienceHost"
|
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="
|
style="
|
||||||
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
|
||||||
-webkit-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
|
<component
|
||||||
:is="activeMemberExperience?.component"
|
:is="activeMemberExperience?.component"
|
||||||
v-if="activeMemberExperience"
|
v-if="activeMemberExperience"
|
||||||
|
|||||||
Reference in New Issue
Block a user