Skip to content

Instantly share code, notes, and snippets.

@RibomBalt
Last active January 31, 2025 12:24
Show Gist options
  • Save RibomBalt/5460de1b5b47bc8ccd3eb855dd1a7e18 to your computer and use it in GitHub Desktop.
Save RibomBalt/5460de1b5b47bc8ccd3eb855dd1a7e18 to your computer and use it in GitHub Desktop.
基于pixi.js的仿sekai转场特效(可导入WebGAL, WebGAL Terre)

使用说明

2. 添加特效对应的文件

新建packages/webgal/src/Core/gameScripts/pixi/performs/sekai.ts文件,把这个文件的内容复制进去。

修改packages/webgal/src/Core/util/pixiPerformManager/initRegister.ts,加这一行:

import '../../gameScripts/pixi/performs/sekai';

2.5 添加Live2D支持(重要)

只要是从官方WebGAL仓库获取代码(包括这里)都需要注意:WebGAL官方为了规避Live2D版权问题,没有在仓库里包含Live2D外部库,并把调用Live2D相关的代码全部注释了,需要根据文档包含库文件,取消注释。

如果是使用群里MyGO2.2整合包的可能之前会忽略这一点,导致最后无法使用Live2D立绘,需要特别注意。

3. 重新编译引擎

可以参考官方教程,我这里也简单描述一下流程:

  1. 安装nodejs(目前引擎版本需要nodejs 18大版本),配置环境变量使得命令行里可以使用node, npm这两个命令
  2. 安装yarnnpm install -g yarn
  3. WebGAL的源码顶层目录启动命令行,执行yarn安装依赖,执行yarn build编译引擎。编译好的引擎应该出现在packages/webgal/dist

4. 把编译后的引擎添加到WebGAL Terre

packages/webgal/dist下的assets文件夹、index.htmlwebgal-serviceworker.js复制到Terre下对应目录中。

5. 调用特效

可参考官方文档

使用pixiInit清空特效舞台,使用pixiPerform:sekai调用特效。

也可以在Terre里可视化添加特效,取消【使用预制特效】选项,特效名填sekai

注:目前的WebGAL我还没搞清楚如何自动在调用特效固定时间后改变背景。我目前的方案是手动改变背景,大致在屏幕完全变白之后点击鼠标可以实现比较丝滑的切换效果

import '../../gameScripts/pixi/performs/cherryBlossoms';
import '../../gameScripts/pixi/performs/rain';
import '../../gameScripts/pixi/performs/snow';
import '../../gameScripts/pixi/performs/sekai';
/*
==============================
仿sekai转场特效
@author: RibomBalt
==============================
*/
import * as PIXI from 'pixi.js';
import { registerPerform } from '@/Core/util/pixiPerformManager/pixiPerformManager';
import { WebGAL } from '@/Core/WebGAL';
import { SCREEN_CONSTANTS } from '@/Core/util/constants';
import { sum } from 'lodash';
// 生成高斯分布随机数
function randn(mean=0, stdev=1) {
const u = 1 - Math.random(); // Converting [0,1) to (0,1]
const v = Math.random();
const z = Math.sqrt( -2.0 * Math.log( u ) ) * Math.cos( 2.0 * Math.PI * v );
// Transform to the desired mean and standard deviation:
return z * stdev + mean;
}
// 从Array中随机选择一个元素
function choose<T>(arr: T[], weight: number[]): T {
const weight_sum = sum(weight);
const cum_weight = weight.map((w, i) => sum(weight.slice(0, i + 1)));
const rand = Math.random() * weight_sum;
for (let i = 0; i < arr.length; i++) {
if (rand < cum_weight[i]) {
return arr[i];
}
}
throw new Error(`choose failed: ${rand} not in [0, ${weight_sum})`);
}
// 实现HSL到RGB的转换(返回[0-1, 0-1, 0-1]范围的RGB数组)
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
// 参数约束
h = ((h % 360) + 360) % 360; // 将色相限制在0-360范围
s = Math.min(1, Math.max(0, s));
l = Math.min(1, Math.max(0, l));
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r: number, g: number, b: number;
if (0 <= h && h < 60) {
[r, g, b] = [c, x, 0];
} else if (60 <= h && h < 120) {
[r, g, b] = [x, c, 0];
} else if (120 <= h && h < 180) {
[r, g, b] = [0, c, x];
} else if (180 <= h && h < 240) {
[r, g, b] = [0, x, c];
} else if (240 <= h && h < 300) {
[r, g, b] = [x, 0, c];
} else {
[r, g, b] = [c, 0, x];
}
return [
Math.round((r + m) * 100) / 100, // 保留两位小数避免精度问题
Math.round((g + m) * 100) / 100,
Math.round((b + m) * 100) / 100
];
};
interface Color {
h: number;
s: number;
l: number;
}
interface TransitionConfig {
speed: number;
spawnPosition: { x: number; y: number };
flipAngleRange: { min: number; max: number };
colorRange: Array<Color>;
weightRange: number[];
alphaRange: { min: number; max: number };
triangleSize: number;
triangleCount: number;
}
interface Triangle {
graphics: PIXI.Graphics;
velocity: { x: number; y: number };
rotationSpeed: number;
currentAngle: number;
damping: number;
randomForce: number;
life: number;
}
const motionAdvance = (triangle: Triangle, delta: number) => {
// https://pixijs.huashengweilai.com/guide/start/10.moving-sprites.html#%E6%B8%B8%E6%88%8F%E5%BE%AA%E7%8E%AF-game-loop
// delta almost is one per frame, usually called 60 fps
// 阻尼
triangle.velocity.x -= triangle.damping * (triangle.life / 30) * triangle.velocity.x * delta;
triangle.velocity.y -= triangle.damping * (triangle.life / 30) * triangle.velocity.x * delta;
// 随机力
triangle.velocity.x += (Math.random() - 0.5) * triangle.randomForce;
triangle.velocity.y += (Math.random() - 0.5) * triangle.randomForce;
// 更新位置
triangle.graphics.x += triangle.velocity.x * delta;
triangle.graphics.y += triangle.velocity.y * delta;
// 更新3D旋转效果
triangle.currentAngle += triangle.rotationSpeed * delta;
const scaleX = Math.cos(triangle.currentAngle);
const skewY = Math.tan(-triangle.currentAngle * 0.5);
triangle.graphics.scale.set(scaleX, 1);
triangle.graphics.skew.set(0, skewY);
// 更新自转
triangle.graphics.rotation += delta * 0.02;
// update life
if (triangle.life > 90) {
triangle.graphics.alpha = Math.max(0, triangle.graphics.alpha - delta * 0.01 * Math.random());
}
triangle.life += delta;
}
const pixiTransition = (config: TransitionConfig) => {
const effectsContainer = WebGAL.gameplay.pixiStage!.effectsContainer!;
const app = WebGAL.gameplay.pixiStage!.currentApp!;
const container = new PIXI.Container();
effectsContainer.addChild(container);
// 创建背景渐变
const background = new PIXI.Graphics();
background.beginFill(0xffffff, 1.0);
background.drawRect(0, 0, SCREEN_CONSTANTS.width, SCREEN_CONSTANTS.height);
background.endFill();
container.addChild(background);
// 动画状态
let backgroundAlpha = 0;
let phase: 'fadeIn' | 'fadeOut' = 'fadeIn';
const triangles: Triangle[] = [];
// 创建三角形池
const createTriangle = () => {
const graphics = new PIXI.Graphics();
const size = config.triangleSize * (0.4 + Math.random() * 0.8);
// 设置颜色
const color = choose(config.colorRange, config.weightRange);
// const hue = config.colorRange.hueMin + Math.random() * (config.colorRange.hueMax - config.colorRange.hueMin);
// const [r, g, b] = hslToRgb(hue, config.colorRange.saturation, config.colorRange.lightness);
const [r, g, b] = hslToRgb(color.h, color.s, color.l);
graphics.tint = PIXI.utils.rgb2hex([r, g, b]);
// 设置透明度
graphics.alpha = config.alphaRange.min + Math.random() * (config.alphaRange.max - config.alphaRange.min);
// 初始化旋转速度
let rotationSpeed = (Math.random() - 0.5) * 0.02;
let currentAngle = config.flipAngleRange.min + Math.random() * (config.flipAngleRange.max - config.flipAngleRange.min)
// 随机形状
const shape_rand = Math.random();
if (shape_rand < 0.5) {
// 绘制等边三角形
graphics.beginFill(0xffffff);
graphics.moveTo(0, size * (Math.sqrt(3) - 1));
graphics.lineTo(size / 2, size * (Math.sqrt(3) - 2));
graphics.lineTo(-size / 2, size * (Math.sqrt(3) - 2));
graphics.closePath();
graphics.endFill();
} else if (shape_rand < 0.8) {
// 绘制空心三角形
graphics.beginFill(0xffffff);
graphics.moveTo(0, size * (Math.sqrt(3) - 1));
graphics.lineTo(size / 2, size * (Math.sqrt(3) - 2));
graphics.lineTo(-size / 2, size * (Math.sqrt(3) - 2));
graphics.closePath();
graphics.endFill();
// create a hole in between
const hole_size = size * 0.9;
graphics.beginHole();
graphics.moveTo(0, hole_size * (Math.sqrt(3) - 1));
graphics.lineTo(hole_size / 2, hole_size * (Math.sqrt(3) - 2));
graphics.lineTo(-hole_size / 2, hole_size * (Math.sqrt(3) - 2));
graphics.closePath();
graphics.endHole();
} else {
// 绘制圆形
graphics.beginFill(0xffffff);
graphics.drawCircle(0, 0, size * 0.2);
graphics.endFill();
graphics.beginHole();
graphics.drawCircle(0, 0, size * 0.2 * 0.9);
graphics.endHole();
// only draw white circle
graphics.tint = 0xffffff;
// for circle, no rotation needed
rotationSpeed = 0;
currentAngle = 0;
}
// 设置初始位置速度
const spawn_x = SCREEN_CONSTANTS.width * (1 + Math.random() * 0.1);
const spawn_y = SCREEN_CONSTANTS.height * randn(0.3, 0.3);
graphics.position.set(spawn_x, spawn_y);
graphics.pivot.set(0.5, 0.5);
graphics.rotation = Math.random() * Math.PI * 2;
// 设置运动参数
const angle = randn(Math.PI, Math.PI / 6);
const speed = config.speed * (0.4 + Math.random() * 0.8);
const velocity = {
x: Math.cos(angle) * speed,
y: Math.sin(angle) * speed
};
const damping = Math.random() * 0.2 / 60;
const randomForce = Math.random() * 0.2;
return {
graphics,
velocity,
rotationSpeed,
currentAngle,
damping,
randomForce,
life: 0,
};
};
// record time, assume single threaded
let total_time = 0;
// 动画更新函数
const tickerFn = (delta: number) => {
// 更新背景渐变
if (phase === 'fadeIn') {
backgroundAlpha = Math.min(1, backgroundAlpha + delta * 0.01);
} else {
backgroundAlpha = Math.max(0, backgroundAlpha - delta * 0.01);
}
background.alpha = backgroundAlpha;
// white background fade out after 4 seconds
if (total_time >= 60 * 4) {
phase = 'fadeOut';
}
// 如果时间不超过3秒,可以以每帧80%的概率生成新三角形
if (total_time < 60 * 5 && Math.random() < 0.8) {
const triangle = createTriangle();
container.addChild(triangle.graphics);
triangles.push(triangle);
}
// 更新三角形状态
triangles.forEach(triangle => {
motionAdvance(triangle, delta);
});
// remove all triangles with alpha = 0
triangles.forEach((triangle, index) => {
if (triangle.graphics.alpha <= 0) {
container.removeChild(triangle.graphics);
triangles.splice(index, 1);
}
});
// update time ticker
total_time += delta;
};
WebGAL.gameplay.pixiStage?.registerAnimation(
{ setStartState: () => { }, setEndState: () => { }, tickerFunc: tickerFn },
'transition-Ticker'
);
return { container, tickerKey: 'transition-Ticker' };
};
// 注册转场特效
registerPerform('sekai', () => pixiTransition({
speed: 30,
spawnPosition: { x: SCREEN_CONSTANTS.width / 2, y: SCREEN_CONSTANTS.height / 2 },
flipAngleRange: { min: -Math.PI / 3, max: Math.PI / 3 },
colorRange: [
{ h: 310, s: 0.8, l: 0.8 },
{ h: 180, s: 0.8, l: 0.8 },
{ h: 60, s: 1.0, l: 0.5 },
{ h: 0, s: 0.0, l: 1.0 },
],
weightRange: [2, 2, 2, 1],
alphaRange: { min: 0.7, max: 0.9 },
triangleSize: 200,
triangleCount: 50
}));
setVar:heroine=WebGAL;
setVar:egine=WebGAL;
; choose:简体中文:demo_zh_cn.txt|日本語:demo_ja.txt|English:demo_en.txt|Test:function_test.txt;
changeBg:bg.png;
:开始sekai特效 -notend;
pixiInit;
pixiPerform:sekai;
: -concat;
changeBg:WebGalEnter.png;
:sekai特效结束
end;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment