|
|
|
|
@ -22,7 +22,7 @@
|
|
|
|
|
|
|
|
|
|
const selectedShapes = (window.YooneSnowSettings && Array.isArray(window.YooneSnowSettings.selectedShapes) && window.YooneSnowSettings.selectedShapes.length > 0)
|
|
|
|
|
? window.YooneSnowSettings.selectedShapes
|
|
|
|
|
: ['dot','flake'];
|
|
|
|
|
: [];
|
|
|
|
|
const mediaItems = (window.YooneSnowSettings && Array.isArray(window.YooneSnowSettings.mediaItems))
|
|
|
|
|
? window.YooneSnowSettings.mediaItems
|
|
|
|
|
: [];
|
|
|
|
|
@ -50,6 +50,48 @@
|
|
|
|
|
const val = typeof shapeWeightsRaw[key] !== 'undefined' ? parseInt(shapeWeightsRaw[key], 10) : defaultShapeWeights[key];
|
|
|
|
|
shapeWeights[key] = isNaN(val) ? defaultShapeWeights[key] : Math.max(0, val);
|
|
|
|
|
}
|
|
|
|
|
const assetsMap = (window.YooneSnowSettings && window.YooneSnowSettings.assetsMap && typeof window.YooneSnowSettings.assetsMap === 'object')
|
|
|
|
|
? window.YooneSnowSettings.assetsMap
|
|
|
|
|
: {};
|
|
|
|
|
const assetShapeKeys = Object.keys(assetsMap || {});
|
|
|
|
|
window.YooneSnowAssetsReady = window.YooneSnowAssetsReady || false;
|
|
|
|
|
window.YooneSnowAssetQueue = window.YooneSnowAssetQueue || [];
|
|
|
|
|
window.YooneSnowAssetQueueRunning = window.YooneSnowAssetQueueRunning || false;
|
|
|
|
|
function enqueueAsset(u){
|
|
|
|
|
if (!u || typeof u !== 'string'){ return; }
|
|
|
|
|
for (var i = 0; i < window.YooneSnowAssetQueue.length; i++){ if (window.YooneSnowAssetQueue[i] === u) return; }
|
|
|
|
|
window.YooneSnowAssetQueue.push(u);
|
|
|
|
|
}
|
|
|
|
|
function runAssetQueue(){
|
|
|
|
|
if (window.YooneSnowAssetQueueRunning){ return; }
|
|
|
|
|
window.YooneSnowAssetQueueRunning = true;
|
|
|
|
|
function next(){
|
|
|
|
|
if (window.YooneSnowAssetQueue.length === 0){ window.YooneSnowAssetQueueRunning = false; window.YooneSnowAssetsReady = true; return; }
|
|
|
|
|
var u = window.YooneSnowAssetQueue.shift();
|
|
|
|
|
if (typeof window.YooneSnowLoadAssetViaFetch === 'function'){
|
|
|
|
|
window.YooneSnowLoadAssetViaFetch(u, function(){ next(); });
|
|
|
|
|
} else if (typeof window.YooneSnowGetOrLoadImage === 'function'){
|
|
|
|
|
var rec = window.YooneSnowGetOrLoadImage(u);
|
|
|
|
|
if (rec && rec.img){
|
|
|
|
|
var prevOnload = rec.img.onload;
|
|
|
|
|
var prevOnerror = rec.img.onerror;
|
|
|
|
|
rec.img.onload = function(){ try{ if (typeof prevOnload === 'function'){ prevOnload(); } }catch(e){} next(); };
|
|
|
|
|
rec.img.onerror = function(){ try{ if (typeof prevOnerror === 'function'){ prevOnerror(); } }catch(e){} next(); };
|
|
|
|
|
} else { next(); }
|
|
|
|
|
} else {
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
window.YooneSnowEnqueueAsset = enqueueAsset;
|
|
|
|
|
function scheduleWarmLoad(){
|
|
|
|
|
var urls = [];
|
|
|
|
|
for (var i = 0; i < assetShapeKeys.length; i++){ var k = assetShapeKeys[i]; var u = assetsMap[k]; if (typeof u === 'string' && u){ urls.push(u); } }
|
|
|
|
|
for (var j = 0; j < urls.length; j++){ enqueueAsset(urls[j]); }
|
|
|
|
|
runAssetQueue();
|
|
|
|
|
}
|
|
|
|
|
scheduleWarmLoad();
|
|
|
|
|
// 移除单独的尺寸与偏移缩放 直接使用最小半径与最小摆动作为缩放系数
|
|
|
|
|
const radiusMinRaw = (window.YooneSnowSettings && typeof window.YooneSnowSettings.radiusMin !== 'undefined')
|
|
|
|
|
? parseFloat(window.YooneSnowSettings.radiusMin)
|
|
|
|
|
@ -76,6 +118,173 @@
|
|
|
|
|
const swingMin = isNaN(swingMinRaw) ? 0.2 : Math.max(0, swingMinRaw);
|
|
|
|
|
const swingMax = isNaN(swingMaxRaw) ? 1.0 : Math.max(swingMin, swingMaxRaw);
|
|
|
|
|
|
|
|
|
|
// 组件与精灵类封装 基于 Cocos 风格
|
|
|
|
|
class Component {
|
|
|
|
|
constructor(){ }
|
|
|
|
|
init(engine, sprite){}
|
|
|
|
|
update(engine, sprite, dt){}
|
|
|
|
|
}
|
|
|
|
|
class DownwardMoveComponent extends Component {
|
|
|
|
|
init(engine, sprite){}
|
|
|
|
|
update(engine, sprite, dt){
|
|
|
|
|
// 使用基于时间的更新 通过帧因子平滑帧率波动
|
|
|
|
|
const factor = Math.max(0.5, Math.min(2.0, dt * 60));
|
|
|
|
|
const vy = (sprite.driftSpeed * 2 + sprite.radius * 0.25) * factor;
|
|
|
|
|
sprite.positionY += vy;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class SwingComponent extends Component {
|
|
|
|
|
init(engine, sprite){}
|
|
|
|
|
update(engine, sprite, dt){
|
|
|
|
|
const factor = Math.max(0.5, Math.min(2.0, dt * 60));
|
|
|
|
|
const vx = Math.sin(sprite.positionY * 0.01) * sprite.swingAmplitude * factor;
|
|
|
|
|
sprite.positionX += vx;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class LifetimeComponent extends Component {
|
|
|
|
|
init(engine, sprite){}
|
|
|
|
|
update(engine, sprite, dt){
|
|
|
|
|
// 超出视口则标记移出 引擎将回收
|
|
|
|
|
if (sprite.positionY > engine.getViewportHeight() + 5){ sprite.outOfView = true; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class Sprite {
|
|
|
|
|
constructor(props){
|
|
|
|
|
this.positionX = props.positionX;
|
|
|
|
|
this.positionY = props.positionY;
|
|
|
|
|
this.radius = props.radius;
|
|
|
|
|
this.driftSpeed = props.driftSpeed;
|
|
|
|
|
this.swingAmplitude = props.swingAmplitude;
|
|
|
|
|
this.shapeType = props.shapeType;
|
|
|
|
|
this.imageUrl = props.imageUrl || null;
|
|
|
|
|
this.emojiText = props.emojiText || null;
|
|
|
|
|
this.outOfView = false;
|
|
|
|
|
this.components = [];
|
|
|
|
|
}
|
|
|
|
|
addComponent(comp){ this.components.push(comp); }
|
|
|
|
|
init(engine){ for (let i = 0; i < this.components.length; i++){ try{ this.components[i].init(engine, this); }catch(e){} } }
|
|
|
|
|
update(engine, dt){ for (let i = 0; i < this.components.length; i++){ try{ this.components[i].update(engine, this, dt); }catch(e){} } }
|
|
|
|
|
render(engine){
|
|
|
|
|
const ctx = engine.context;
|
|
|
|
|
const registry = window.YooneSnowShapeRenderers || {};
|
|
|
|
|
const renderer = registry[this.shapeType] || registry['dot'];
|
|
|
|
|
if (typeof renderer === 'function'){ renderer(ctx, this.positionX, this.positionY, this.radius); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class Snow extends Sprite {
|
|
|
|
|
constructor(props){ super(props); }
|
|
|
|
|
render(engine){
|
|
|
|
|
const ctx = engine.context;
|
|
|
|
|
if (this.shapeType === 'media_image' && this.imageUrl){
|
|
|
|
|
const record = window.YooneSnowGetOrLoadImage ? window.YooneSnowGetOrLoadImage(this.imageUrl) : { img: null, ready: false };
|
|
|
|
|
if (record && record.ready){
|
|
|
|
|
const targetHeight = this.radius * 8;
|
|
|
|
|
const targetWidth = targetHeight;
|
|
|
|
|
window.YooneSnowDrawCenteredImage(ctx, record.img, this.positionX, this.positionY, targetWidth, targetHeight);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.shapeType === 'emoji_text' && this.emojiText){
|
|
|
|
|
ctx.save();
|
|
|
|
|
const fontSize = Math.max(12, this.radius * 6);
|
|
|
|
|
ctx.font = String(Math.floor(fontSize)) + 'px system-ui, Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji';
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
ctx.textBaseline = 'middle';
|
|
|
|
|
ctx.fillText(String(this.emojiText), this.positionX, this.positionY);
|
|
|
|
|
ctx.restore();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.shapeType === 'text_label' && this.emojiText){
|
|
|
|
|
ctx.save();
|
|
|
|
|
const fontSize = Math.max(12, this.radius * 5.5);
|
|
|
|
|
ctx.font = String(Math.floor(fontSize)) + 'px system-ui, -apple-system, Segoe UI, Roboto, Noto Sans';
|
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
ctx.textBaseline = 'middle';
|
|
|
|
|
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
|
|
|
|
ctx.fillText(String(this.emojiText), this.positionX, this.positionY);
|
|
|
|
|
ctx.restore();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const registry = window.YooneSnowShapeRenderers || {};
|
|
|
|
|
const renderer = registry[this.shapeType] || registry['dot'];
|
|
|
|
|
if (typeof renderer === 'function'){ renderer(ctx, this.positionX, this.positionY, this.radius); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 简易引擎引用 用于组件获取上下文与视口尺寸 始终返回最新值
|
|
|
|
|
const engineRef = {
|
|
|
|
|
getViewportWidth: function(){ return viewportWidth; },
|
|
|
|
|
getViewportHeight: function(){ return viewportHeight; },
|
|
|
|
|
context: context
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class Animator {
|
|
|
|
|
constructor(onFrame, isDone, onStopped){
|
|
|
|
|
this.onFrame = onFrame;
|
|
|
|
|
this.isDone = isDone;
|
|
|
|
|
this.onStopped = onStopped;
|
|
|
|
|
this.lastTs = performance.now();
|
|
|
|
|
this.rafId = null;
|
|
|
|
|
this.running = false;
|
|
|
|
|
this.loop = this.loop.bind(this);
|
|
|
|
|
}
|
|
|
|
|
init(){
|
|
|
|
|
this.lastTs = performance.now();
|
|
|
|
|
this.running = false;
|
|
|
|
|
}
|
|
|
|
|
loop(){
|
|
|
|
|
if (!this.running) return;
|
|
|
|
|
const nowTs = performance.now();
|
|
|
|
|
const deltaSeconds = Math.max(0, (nowTs - this.lastTs) / 1000);
|
|
|
|
|
this.lastTs = nowTs;
|
|
|
|
|
if (typeof this.onFrame === 'function'){ this.onFrame(deltaSeconds); }
|
|
|
|
|
const done = typeof this.isDone === 'function' ? this.isDone() : false;
|
|
|
|
|
if (done){
|
|
|
|
|
this.stop();
|
|
|
|
|
if (typeof this.onStopped === 'function'){ this.onStopped(); }
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.rafId = requestAnimationFrame(this.loop);
|
|
|
|
|
}
|
|
|
|
|
update(){
|
|
|
|
|
this.running = true;
|
|
|
|
|
this.rafId = requestAnimationFrame(this.loop);
|
|
|
|
|
}
|
|
|
|
|
stop(){
|
|
|
|
|
if (this.rafId !== null && typeof cancelAnimationFrame === 'function'){
|
|
|
|
|
try{ cancelAnimationFrame(this.rafId); }catch(e){}
|
|
|
|
|
this.rafId = null;
|
|
|
|
|
}
|
|
|
|
|
this.running = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
class SnowAnimator extends Animator {
|
|
|
|
|
constructor(){
|
|
|
|
|
super(function(dt){ if (typeof this.onFrameImpl === 'function'){ this.onFrameImpl(dt); } }, function(){ return typeof this.isDoneImpl === 'function' ? this.isDoneImpl() : false; }, function(){ if (typeof this.onStoppedImpl === 'function'){ this.onStoppedImpl(); } });
|
|
|
|
|
this.onFrameImpl = null;
|
|
|
|
|
this.isDoneImpl = null;
|
|
|
|
|
this.onStoppedImpl = null;
|
|
|
|
|
this.targetCount = 0;
|
|
|
|
|
}
|
|
|
|
|
computeTargetCount(){
|
|
|
|
|
this.targetCount = snowflakesTargetCount;
|
|
|
|
|
return this.targetCount;
|
|
|
|
|
}
|
|
|
|
|
init(){
|
|
|
|
|
this.computeTargetCount();
|
|
|
|
|
initSnowPrefill();
|
|
|
|
|
this.onFrameImpl = function(deltaSeconds){ updateSystem(deltaSeconds); renderSystem(); };
|
|
|
|
|
this.isDoneImpl = function(){ return shouldStop(); };
|
|
|
|
|
this.onStoppedImpl = function(){ cleanupStop(); };
|
|
|
|
|
super.init();
|
|
|
|
|
}
|
|
|
|
|
update(){
|
|
|
|
|
super.update();
|
|
|
|
|
}
|
|
|
|
|
stop(){
|
|
|
|
|
super.stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 调整画布尺寸并设置像素比 保证清晰显示
|
|
|
|
|
function resizeCanvas(){
|
|
|
|
|
viewportWidth = window.innerWidth;
|
|
|
|
|
@ -91,20 +300,58 @@
|
|
|
|
|
|
|
|
|
|
resizeCanvas();
|
|
|
|
|
|
|
|
|
|
// 读取在屏最大数量设置 0 表示自动根据视口面积
|
|
|
|
|
const maxCountRaw = (window.YooneSnowSettings && typeof window.YooneSnowSettings.maxCount !== 'undefined')
|
|
|
|
|
? parseInt(window.YooneSnowSettings.maxCount, 10)
|
|
|
|
|
: 0;
|
|
|
|
|
const isSmallScreen = Math.min(viewportWidth, viewportHeight) <= 768;
|
|
|
|
|
const computedAutoCount = Math.floor(Math.min(isSmallScreen ? 240 : 400, Math.max(isSmallScreen ? 80 : 120, (viewportWidth * viewportHeight) / (isSmallScreen ? 24000 : 12000))));
|
|
|
|
|
const snowflakesTargetCount = Math.max(1, (isNaN(maxCountRaw) || maxCountRaw <= 0) ? computedAutoCount : maxCountRaw);
|
|
|
|
|
// 读取分屏最大数量设置 0 表示自动 根据屏幕最小边界划分为 small medium large
|
|
|
|
|
function computeAutoCount(kind){
|
|
|
|
|
const area = viewportWidth * viewportHeight;
|
|
|
|
|
if (kind === 'small'){
|
|
|
|
|
const est = area / 36000;
|
|
|
|
|
return Math.floor(Math.min(80, Math.max(40, est)));
|
|
|
|
|
}
|
|
|
|
|
if (kind === 'medium'){
|
|
|
|
|
// 中屏适中限制到 [100 200]
|
|
|
|
|
const est = area / 18000;
|
|
|
|
|
return Math.floor(Math.min(200, Math.max(100, est)));
|
|
|
|
|
}
|
|
|
|
|
// 大屏较多但仍限制到 [140 300]
|
|
|
|
|
const est = area / 12000;
|
|
|
|
|
return Math.floor(Math.min(300, Math.max(140, est)));
|
|
|
|
|
}
|
|
|
|
|
const minDim = Math.min(viewportWidth, viewportHeight);
|
|
|
|
|
const rawSmall = (window.YooneSnowSettings && typeof window.YooneSnowSettings.maxCountSmall !== 'undefined') ? parseInt(window.YooneSnowSettings.maxCountSmall, 10) : 0;
|
|
|
|
|
const rawMedium = (window.YooneSnowSettings && typeof window.YooneSnowSettings.maxCountMedium !== 'undefined') ? parseInt(window.YooneSnowSettings.maxCountMedium, 10) : 0;
|
|
|
|
|
const rawLarge = (window.YooneSnowSettings && typeof window.YooneSnowSettings.maxCountLarge !== 'undefined') ? parseInt(window.YooneSnowSettings.maxCountLarge, 10) : 0;
|
|
|
|
|
const fallbackSingle = (window.YooneSnowSettings && typeof window.YooneSnowSettings.maxCount !== 'undefined') ? parseInt(window.YooneSnowSettings.maxCount, 10) : 0;
|
|
|
|
|
let snowflakesTargetCount = 0;
|
|
|
|
|
if (minDim <= 480){
|
|
|
|
|
snowflakesTargetCount = (isNaN(rawSmall) || rawSmall <= 0) ? computeAutoCount('small') : rawSmall;
|
|
|
|
|
} else if (minDim <= 960){
|
|
|
|
|
snowflakesTargetCount = (isNaN(rawMedium) || rawMedium <= 0) ? computeAutoCount('medium') : rawMedium;
|
|
|
|
|
} else {
|
|
|
|
|
snowflakesTargetCount = (isNaN(rawLarge) || rawLarge <= 0) ? computeAutoCount('large') : rawLarge;
|
|
|
|
|
}
|
|
|
|
|
if (snowflakesTargetCount <= 0){
|
|
|
|
|
// 回退到旧单值设置 仍支持 0 自动
|
|
|
|
|
snowflakesTargetCount = (isNaN(fallbackSingle) || fallbackSingle <= 0) ? computeAutoCount(minDim <= 480 ? 'small' : (minDim <= 960 ? 'medium' : 'large')) : fallbackSingle;
|
|
|
|
|
}
|
|
|
|
|
snowflakesTargetCount = Math.max(1, snowflakesTargetCount);
|
|
|
|
|
const snowflakes = [];
|
|
|
|
|
// 定义连续生成控制参数 使用时间积累的方式平滑新增
|
|
|
|
|
let spawnAccumulator = 0;
|
|
|
|
|
let lastUpdateTimestamp = performance.now();
|
|
|
|
|
let rafId = null;
|
|
|
|
|
// 定义黄金比例相位用于水平位置分布避免聚集
|
|
|
|
|
let spawnPhase = Math.random();
|
|
|
|
|
const goldenRatio = 0.61803398875;
|
|
|
|
|
var yooneLogEntries = Array.isArray(window.YooneSnowLogEntries) ? window.YooneSnowLogEntries : [];
|
|
|
|
|
window.YooneSnowLogEntries = yooneLogEntries;
|
|
|
|
|
function yooneLogPush(entry){
|
|
|
|
|
try {
|
|
|
|
|
yooneLogEntries.push(Object.assign({ ts: performance.now() }, entry));
|
|
|
|
|
if (yooneLogEntries.length > 1500){ yooneLogEntries.shift(); }
|
|
|
|
|
// 关闭默认的控制台输出 避免在高频帧中阻塞主线程导致动画卡顿
|
|
|
|
|
} catch(e){}
|
|
|
|
|
}
|
|
|
|
|
window.YooneSnowGetLog = function(){ return yooneLogEntries.slice(); };
|
|
|
|
|
// 函数 按权重选择形状或媒体图像
|
|
|
|
|
function selectWeightedItem(){
|
|
|
|
|
const items = [];
|
|
|
|
|
@ -112,7 +359,10 @@
|
|
|
|
|
const shapeKey = selectedShapes[sIndex];
|
|
|
|
|
const weightVal = typeof shapeWeights[shapeKey] !== 'undefined' ? shapeWeights[shapeKey] : 1;
|
|
|
|
|
if (weightVal > 0){
|
|
|
|
|
// 条件判断 普通形状直接加入 不含 emoji
|
|
|
|
|
if (assetShapeKeys.indexOf(shapeKey) >= 0){
|
|
|
|
|
const aurl = assetsMap[shapeKey];
|
|
|
|
|
if (aurl && typeof window.YooneSnowEnqueueAsset === 'function'){ window.YooneSnowEnqueueAsset(aurl); }
|
|
|
|
|
}
|
|
|
|
|
items.push({ kind: 'shape', key: shapeKey, weight: weightVal });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -140,12 +390,12 @@
|
|
|
|
|
const mediaWeight = typeof mediaWeightsRaw[mediaUrl] !== 'undefined' ? parseInt(mediaWeightsRaw[mediaUrl], 10) : 1;
|
|
|
|
|
const finalMediaWeight = isNaN(mediaWeight) ? 1 : Math.max(0, mediaWeight);
|
|
|
|
|
if (finalMediaWeight > 0){
|
|
|
|
|
items.push({ kind: 'media', url: mediaUrl, weight: finalMediaWeight });
|
|
|
|
|
const rec = (typeof window.YooneSnowGetOrLoadImage === 'function' && mediaUrl) ? window.YooneSnowGetOrLoadImage(mediaUrl) : null;
|
|
|
|
|
if (rec && rec.ready){ items.push({ kind: 'media', url: mediaUrl, weight: finalMediaWeight }); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 条件判断 如果没有可选项则回退为点形状
|
|
|
|
|
if (items.length === 0){
|
|
|
|
|
return { type: 'dot', url: null, text: null };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
let totalWeight = 0;
|
|
|
|
|
for (let i = 0; i < items.length; i++){
|
|
|
|
|
@ -172,27 +422,29 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { type: 'dot', url: null, text: null };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
function createSnowflake(preferredX, preferredY){
|
|
|
|
|
const picked = selectWeightedItem();
|
|
|
|
|
if (!picked){ return null; }
|
|
|
|
|
let chosenType = picked.type;
|
|
|
|
|
let chosenImageUrl = picked.url;
|
|
|
|
|
let chosenEmojiText = picked.text;
|
|
|
|
|
return {
|
|
|
|
|
const sprite = new Snow({
|
|
|
|
|
positionX: typeof preferredX === 'number' ? preferredX : Math.random() * viewportWidth,
|
|
|
|
|
positionY: typeof preferredY === 'number' ? preferredY : (-1 - Math.random() * 4),
|
|
|
|
|
// 半径使用随机范围并乘以最小半径作为缩放因子
|
|
|
|
|
radius: (Math.random() * (radiusMax - radiusMin) + radiusMin) * radiusMin,
|
|
|
|
|
driftSpeed: Math.random() * (driftMax - driftMin) + driftMin,
|
|
|
|
|
// 水平摆动使用随机范围并乘以最小摆动作为缩放因子
|
|
|
|
|
swingAmplitude: (Math.random() * (swingMax - swingMin) + swingMin) * swingMin,
|
|
|
|
|
shapeType: chosenType,
|
|
|
|
|
imageUrl: chosenImageUrl,
|
|
|
|
|
emojiText: chosenEmojiText,
|
|
|
|
|
// 标记该粒子是否已经移出视口 用于停止后清理
|
|
|
|
|
outOfView: false
|
|
|
|
|
};
|
|
|
|
|
emojiText: chosenEmojiText
|
|
|
|
|
});
|
|
|
|
|
sprite.addComponent(new DownwardMoveComponent());
|
|
|
|
|
sprite.addComponent(new SwingComponent());
|
|
|
|
|
sprite.addComponent(new LifetimeComponent());
|
|
|
|
|
sprite.init(engineRef);
|
|
|
|
|
return sprite;
|
|
|
|
|
}
|
|
|
|
|
// 计算平均垂直速度 辅助估算生成速率 保证视觉连续
|
|
|
|
|
function computeAverageVerticalSpeed(){
|
|
|
|
|
@ -201,7 +453,7 @@
|
|
|
|
|
for (let idx = 0; idx < snowflakes.length; idx++){
|
|
|
|
|
const flake = snowflakes[idx];
|
|
|
|
|
if (flake.outOfView){ continue; }
|
|
|
|
|
const verticalSpeed = flake.driftSpeed * 2 + flake.radius * 0.25;
|
|
|
|
|
const verticalSpeed = (flake.driftSpeed * 2 + flake.radius * 0.25) * 60;
|
|
|
|
|
speedSum += verticalSpeed;
|
|
|
|
|
countInView++;
|
|
|
|
|
}
|
|
|
|
|
@ -210,27 +462,27 @@
|
|
|
|
|
}
|
|
|
|
|
const driftAverage = (driftMin + driftMax) * 0.5;
|
|
|
|
|
const radiusAverage = ((radiusMin + radiusMax) * 0.5) * radiusMin;
|
|
|
|
|
return driftAverage * 2 + radiusAverage * 0.25;
|
|
|
|
|
return (driftAverage * 2 + radiusAverage * 0.25) * 60;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 根据视口高度 目标最大数量 与平均速度估算初始化预填充数量
|
|
|
|
|
function estimateInitialPrefillCount(){
|
|
|
|
|
// 計算平均垂直速度 用於估算雪花生命周期
|
|
|
|
|
const averageVerticalSpeed = computeAverageVerticalSpeed();
|
|
|
|
|
const averageLifeSeconds = (viewportHeight + 5) / Math.max(0.001, averageVerticalSpeed);
|
|
|
|
|
// 計算每秒需要補給的雪花數量 以維持目標密度
|
|
|
|
|
const supplyRatePerSecond = snowflakesTargetCount / Math.max(0.001, averageLifeSeconds);
|
|
|
|
|
// 設置預熱時長 避免開場過空或過密 隨速度自適應
|
|
|
|
|
const warmupSeconds = Math.min(2.0, Math.max(0.8, averageLifeSeconds * 0.25));
|
|
|
|
|
// 初始預填充數量 取補給速率乘以預熱時長 並限制在合理範圍
|
|
|
|
|
const warmupSeconds = Math.min(1.2, Math.max(0.6, averageLifeSeconds * 0.2));
|
|
|
|
|
const rawCount = Math.floor(supplyRatePerSecond * warmupSeconds);
|
|
|
|
|
const boundedCount = Math.min(snowflakesTargetCount, Math.max(10, rawCount));
|
|
|
|
|
const maxInitialFraction = 0.45;
|
|
|
|
|
const capByFraction = Math.floor(snowflakesTargetCount * maxInitialFraction);
|
|
|
|
|
const boundedCount = Math.max(8, Math.min(capByFraction, rawCount));
|
|
|
|
|
return boundedCount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 封装添加雪花的操作 支持指定水平与垂直位置
|
|
|
|
|
function pushSnowflake(preferredX, preferredY){
|
|
|
|
|
snowflakes.push(createSnowflake(preferredX, preferredY));
|
|
|
|
|
var flake = createSnowflake(preferredX, preferredY);
|
|
|
|
|
if (flake){ snowflakes.push(flake); return true; }
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 初始化预填充雪花 保持从顶部下落的感觉并避免过量
|
|
|
|
|
@ -239,12 +491,13 @@
|
|
|
|
|
if (hasReachedDuration){ return; }
|
|
|
|
|
if (snowflakes.length > 0){ return; }
|
|
|
|
|
const initialCount = estimateInitialPrefillCount();
|
|
|
|
|
yooneLogPush({ kind: 'prefill', count: initialCount, target: snowflakesTargetCount });
|
|
|
|
|
for (let spawnIndex = 0; spawnIndex < initialCount; spawnIndex++){
|
|
|
|
|
// 使用黄金比例相位分布水平位置 减少聚集
|
|
|
|
|
const preferredX = (spawnPhase % 1) * viewportWidth;
|
|
|
|
|
spawnPhase = (spawnPhase + goldenRatio) % 1;
|
|
|
|
|
// 仅在顶部附近生成 保持从顶部下落的视觉感受
|
|
|
|
|
const preferredY = -3 - Math.random() * 6;
|
|
|
|
|
// 预填充垂直位置覆盖顶部到中段 减少中段稀疏
|
|
|
|
|
const preferredY = -Math.random() * (viewportHeight * 0.4);
|
|
|
|
|
pushSnowflake(preferredX, preferredY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -253,14 +506,16 @@
|
|
|
|
|
initSnowPrefill();
|
|
|
|
|
|
|
|
|
|
// 函数 更新雪花位置与视口状态
|
|
|
|
|
function updateSnowflakes(){
|
|
|
|
|
function updateSnowflakes(deltaSeconds){
|
|
|
|
|
for (let j = 0; j < snowflakes.length; j++){
|
|
|
|
|
const flake = snowflakes[j];
|
|
|
|
|
flake.positionY += flake.driftSpeed * 2 + flake.radius * 0.25;
|
|
|
|
|
flake.positionX += Math.sin(flake.positionY * 0.01) * flake.swingAmplitude;
|
|
|
|
|
// 条件判断 如果超出视口底部则标记为已移出
|
|
|
|
|
if (flake.positionY > viewportHeight + 5){
|
|
|
|
|
flake.outOfView = true;
|
|
|
|
|
if (flake && typeof flake.update === 'function'){
|
|
|
|
|
flake.update(engineRef, deltaSeconds);
|
|
|
|
|
} else {
|
|
|
|
|
const frameFactor = Math.max(0.5, Math.min(2.0, deltaSeconds * 60));
|
|
|
|
|
flake.positionY += (flake.driftSpeed * 2 + flake.radius * 0.25) * frameFactor;
|
|
|
|
|
flake.positionX += Math.sin(flake.positionY * 0.01) * flake.swingAmplitude * frameFactor;
|
|
|
|
|
if (flake.positionY > viewportHeight + 5){ flake.outOfView = true; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -270,48 +525,15 @@
|
|
|
|
|
// 函数 清空画布并绘制可见雪花
|
|
|
|
|
function drawSnowflakes(){
|
|
|
|
|
context.clearRect(0, 0, viewportWidth, viewportHeight);
|
|
|
|
|
|
|
|
|
|
for (let k = 0; k < snowflakes.length; k++){
|
|
|
|
|
const flake = snowflakes[k];
|
|
|
|
|
// 条件判断 如果该粒子已移出视口则不再绘制
|
|
|
|
|
if (flake.outOfView){ continue; }
|
|
|
|
|
// 条件判断 如果是媒体图片类型则使用通用图像渲染
|
|
|
|
|
if (flake.shapeType === 'media_image' && flake.imageUrl){
|
|
|
|
|
const record = window.YooneSnowGetOrLoadImage ? window.YooneSnowGetOrLoadImage(flake.imageUrl) : { img: null, ready: false };
|
|
|
|
|
if (record && record.ready){
|
|
|
|
|
const targetHeight = flake.radius * 8;
|
|
|
|
|
const targetWidth = targetHeight;
|
|
|
|
|
window.YooneSnowDrawCenteredImage(context, record.img, flake.positionX, flake.positionY, targetWidth, targetHeight);
|
|
|
|
|
}
|
|
|
|
|
if (flake && typeof flake.render === 'function'){
|
|
|
|
|
try{ flake.render(engineRef); }catch(e){}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// 条件判断 如果是 emoji 文本类型則使用文本繪制
|
|
|
|
|
if (flake.shapeType === 'emoji_text' && flake.emojiText){
|
|
|
|
|
context.save();
|
|
|
|
|
// 設置字體大小與居中對齊 基於半徑縮放
|
|
|
|
|
const fontSize = Math.max(12, flake.radius * 6);
|
|
|
|
|
context.font = String(Math.floor(fontSize)) + 'px system-ui, Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji';
|
|
|
|
|
context.textAlign = 'center';
|
|
|
|
|
context.textBaseline = 'middle';
|
|
|
|
|
context.fillText(String(flake.emojiText), flake.positionX, flake.positionY);
|
|
|
|
|
context.restore();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (flake.shapeType === 'text_label' && flake.emojiText){
|
|
|
|
|
context.save();
|
|
|
|
|
const fontSize = Math.max(12, flake.radius * 5.5);
|
|
|
|
|
context.font = String(Math.floor(fontSize)) + 'px system-ui, -apple-system, Segoe UI, Roboto, Noto Sans';
|
|
|
|
|
context.textAlign = 'center';
|
|
|
|
|
context.textBaseline = 'middle';
|
|
|
|
|
context.fillStyle = 'rgba(255,255,255,0.9)';
|
|
|
|
|
context.fillText(String(flake.emojiText), flake.positionX, flake.positionY);
|
|
|
|
|
context.restore();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// 否则执行注册表中的形状渲染函数
|
|
|
|
|
const registry = window.YooneSnowShapeRenderers || {};
|
|
|
|
|
const renderer = registry[flake.shapeType] || registry['dot'];
|
|
|
|
|
// 条件判断 只有在渲染器为函数时才执行绘制
|
|
|
|
|
if (typeof renderer === 'function'){
|
|
|
|
|
renderer(context, flake.positionX, flake.positionY, flake.radius);
|
|
|
|
|
}
|
|
|
|
|
@ -350,15 +572,17 @@
|
|
|
|
|
const maxPerFrame = Math.max(1, Math.floor(snowflakesTargetCount * 0.05));
|
|
|
|
|
if (spawnCount > maxPerFrame){ spawnCount = maxPerFrame; }
|
|
|
|
|
if (spawnCount > 0){
|
|
|
|
|
spawnAccumulator = Math.max(0, spawnAccumulator - spawnCount);
|
|
|
|
|
yooneLogPush({ kind: 'frame', count: spawnCount, availableSlots: availableSlots, length: snowflakes.length, target: snowflakesTargetCount, accumulator: spawnAccumulator });
|
|
|
|
|
var added = 0;
|
|
|
|
|
for (let s = 0; s < spawnCount; s++){
|
|
|
|
|
const preferredX = (spawnPhase % 1) * viewportWidth;
|
|
|
|
|
spawnPhase = (spawnPhase + goldenRatio) % 1;
|
|
|
|
|
pushSnowflake(preferredX, undefined);
|
|
|
|
|
if (pushSnowflake(preferredX, undefined)) { added++; }
|
|
|
|
|
}
|
|
|
|
|
if (added > 0){ spawnAccumulator = Math.max(0, spawnAccumulator - added); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
updateSnowflakes();
|
|
|
|
|
updateSnowflakes(deltaSeconds);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 渲染系统 清空画布并绘制
|
|
|
|
|
@ -367,30 +591,18 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 函数 动画主循环 包含生成 渲染 与停止逻辑
|
|
|
|
|
function animate(){
|
|
|
|
|
const nowTs = performance.now();
|
|
|
|
|
const deltaSeconds = Math.max(0, (nowTs - lastUpdateTimestamp) / 1000);
|
|
|
|
|
lastUpdateTimestamp = nowTs;
|
|
|
|
|
updateSystem(deltaSeconds);
|
|
|
|
|
renderSystem();
|
|
|
|
|
// 条件判断 如果设置了有限时长则在达到时长后不再生成新粒子并等待自然落出
|
|
|
|
|
function shouldStop(){
|
|
|
|
|
if (displayDurationSeconds > 0 && !hasReachedDuration){
|
|
|
|
|
const elapsedSeconds = (performance.now() - startTimestamp) / 1000;
|
|
|
|
|
if (elapsedSeconds >= displayDurationSeconds){
|
|
|
|
|
hasReachedDuration = true;
|
|
|
|
|
}
|
|
|
|
|
if (elapsedSeconds >= displayDurationSeconds){ hasReachedDuration = true; }
|
|
|
|
|
}
|
|
|
|
|
// 条件判断 如果已达到时长且所有粒子都移出视口则执行清理停止
|
|
|
|
|
if (hasReachedDuration){
|
|
|
|
|
const allOut = snowflakes.every(function(f){ return f.outOfView; });
|
|
|
|
|
if (allOut){
|
|
|
|
|
cleanupStop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (allOut){ return true; }
|
|
|
|
|
}
|
|
|
|
|
// 调用动画循环 保持持续更新
|
|
|
|
|
requestAnimationFrame(animate);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const animator = new SnowAnimator();
|
|
|
|
|
|
|
|
|
|
// 定义窗口尺寸事件处理器 以便在停止时移除
|
|
|
|
|
function onResize(){
|
|
|
|
|
@ -400,14 +612,14 @@
|
|
|
|
|
viewportHeight = window.innerHeight;
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('resize', onResize);
|
|
|
|
|
|
|
|
|
|
animate();
|
|
|
|
|
animator.init();
|
|
|
|
|
animator.update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 条件判断 如果文档尚未加载则等待 DOMContentLoaded 事件
|
|
|
|
|
if (document.readyState === 'loading'){
|
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
|
} else {
|
|
|
|
|
if (document.readyState === 'complete'){
|
|
|
|
|
init();
|
|
|
|
|
} else {
|
|
|
|
|
window.addEventListener('load', init);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|