更新迭代动画效果

This commit is contained in:
WIN-GKKD951VVMJ\Administrator 2026-09-15 18:31:24 +08:00
parent 5d408e3434
commit 2029441aae
11 changed files with 570 additions and 186 deletions

View File

@ -1,4 +1,5 @@
import { applyRainbowCut } from "./RainbowCut";
import RainbowTrail from "./RainbowTrail";
import { chooseRainbowTarget, rainbowPriority, RainbowEnergy, RAINBOW_CUTS, RAINBOW_CAPACITY, RAINBOW_CAST_SECONDS } from "./RainbowRules";
/** 每关独立持有;原地复活不重建,不占用暂停或道具的状态。 */
@ -10,7 +11,8 @@ export default class RainbowStreak {
private credited = new Set<cc.Node>();
private bottle: cc.Node;
private shield: cc.Node = null;
private flight: cc.Node = null;
private flight: RainbowTrail = null;
private fadingFlights: RainbowTrail[] = [];
private elapsed = 0;
private selection: any = null;
private from: cc.Vec2;
@ -112,14 +114,28 @@ export default class RainbowStreak {
}
update(dt: number): void {
if (this.map.gameOver || this.map.gameWin) { if (this.casting) this.finish(false); return; }
if (this.map.gameOver || this.map.gameWin) {
if (this.casting) this.finish(false);
this.clearTrails();
return;
}
const paused = !this.counting();
if (this.flight) this.flight.update(paused);
this.fadingFlights = this.fadingFlights.filter(trail => trail.update(paused));
if (this.hidden) return;
if (this.energy.stopped && !this.casting) return;
if (this.casting && this.counting()) {
this.elapsed += dt;
const t = Math.min(1, this.elapsed / RAINBOW_CAST_SECONDS);
if (this.flight) this.flight.setPosition(this.from.x + (this.to.x - this.from.x) * t,
this.from.y + (this.to.y - this.from.y) * t + 100 * 4 * t * (1 - t));
if (this.flight) {
// 先抛到目标上方,再竖直落入目标格;弧顶比两端高 180。
const top = Math.max(this.from.y, this.to.y) + 180;
const controlY = top + Math.sqrt((top - this.from.y) * (top - this.to.y));
const u = 1 - t;
this.flight.node.setPosition(this.to.x + (this.from.x - this.to.x) * u * u,
u * u * this.from.y + 2 * u * t * controlY + t * t * this.to.y);
this.flight.setProgress(t);
}
if (t >= 1) this.finish(true);
}
const candidates = this.refreshAvailability();
@ -146,15 +162,12 @@ export default class RainbowStreak {
canvas.addChild(this.shield);
this.shield.zIndex = cc.macro.MAX_ZINDEX;
if (this.map.magics && this.bottle) {
this.flight = cc.instantiate(this.map.magics);
this.flight.parent = this.map.magics.parent;
this.flight.active = true;
this.flight.zIndex = cc.macro.MAX_ZINDEX;
this.from = this.flight.parent.convertToNodeSpaceAR(this.bottle.convertToWorldSpaceAR(cc.v2(0, 0)));
const parent = this.map.magics.parent;
this.from = parent.convertToNodeSpaceAR(this.bottle.convertToWorldSpaceAR(cc.v2(0, 0)));
const cell = this.map.mapBlocksWall[block.posX + choice.cut.cell.x][block.posY + choice.cut.cell.y];
this.to = this.flight.parent.convertToNodeSpaceAR(cell.parent.convertToWorldSpaceAR(cc.v2(cell.x, cell.y)));
this.flight.setPosition(this.from);
this.flight.getComponentsInChildren(cc.ParticleSystem).forEach(particle => particle.resetSystem());
this.to = parent.convertToNodeSpaceAR(cell.parent.convertToWorldSpaceAR(cc.v2(cell.x, cell.y)));
this.flight = new RainbowTrail(parent, RAINBOW_CAST_SECONDS);
this.flight.node.setPosition(this.from);
}
}
@ -172,7 +185,11 @@ export default class RainbowStreak {
this.casting = false;
this.selection = null;
if (this.shield) { this.shield.active = false; this.shield.destroy(); this.shield = null; }
if (this.flight) { this.flight.destroy(); this.flight = null; }
if (this.flight) {
if (apply) { this.flight.stop(); this.fadingFlights.push(this.flight); }
else this.flight.destroy();
this.flight = null;
}
const block = this.finger;
this.finger = null;
if (apply && block && cc.isValid(block.node, true) && block.isTouch) {
@ -194,7 +211,13 @@ export default class RainbowStreak {
dispose(): void {
this.finish(false);
this.clearTrails();
cc.game.off(cc.game.EVENT_HIDE, this.hide);
cc.game.off(cc.game.EVENT_SHOW, this.show);
}
private clearTrails(): void {
this.fadingFlights.forEach(trail => trail.destroy());
this.fadingFlights = [];
}
}

View File

@ -0,0 +1,144 @@
// 彩虹飞行专用粒子,纹理在内存中生成,避免改动魔法棒的共享特效。
export default class RainbowTrail {
readonly node = new cc.Node("rainbowFlight");
private mist: cc.ParticleSystem;
private stars: cc.ParticleSystem;
private head: cc.Node;
private frames: cc.SpriteFrame[] = [];
private textures: cc.Texture2D[] = [];
private stopped = false;
constructor(parent: cc.Node, duration: number) {
const glow = this.texture(false);
const star = this.texture(true);
this.mist = this.emitter("rainbowMist", glow);
this.mist.totalParticles = 200;
this.mist.emissionRate = 170;
this.mist.life = Math.max(0.45, duration * 0.6);
this.mist.lifeVar = 0.08;
this.mist.startSize = 58;
this.mist.startSizeVar = 10;
this.mist.endSize = 24;
this.mist.endSizeVar = 6;
this.mist.posVar = cc.v2(3, 3);
this.mist.speed = 6;
this.mist.speedVar = 4;
this.stars = this.emitter("rainbowStars", star);
this.stars.totalParticles = 30;
this.stars.emissionRate = 26;
this.stars.life = 0.55;
this.stars.lifeVar = 0.1;
this.stars.startSize = 15;
this.stars.startSizeVar = 5;
this.stars.endSize = 3;
this.stars.endSizeVar = 2;
this.stars.posVar = cc.v2(13, 13);
this.stars.speed = 16;
this.stars.speedVar = 8;
this.stars.startSpinVar = 180;
this.stars.endSpinVar = 180;
this.stars.startColor = cc.color(255, 244, 178, 255);
this.stars.startColorVar = cc.color(0, 10, 45, 0);
this.stars.endColor = cc.color(255, 225, 130, 0);
this.head = new cc.Node("rainbowHead");
const sprite = this.head.addComponent(cc.Sprite);
sprite.spriteFrame = glow;
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM;
this.head.setContentSize(42, 42);
this.head.color = cc.color(255, 252, 224);
this.node.addChild(this.head);
this.setProgress(0);
parent.addChild(this.node);
this.node.zIndex = cc.macro.MAX_ZINDEX;
this.mist.resetSystem();
this.stars.resetSystem();
}
private emitter(name: string, frame: cc.SpriteFrame): cc.ParticleSystem {
const node = new cc.Node(name);
const particles = node.addComponent(cc.ParticleSystem);
particles.custom = true;
particles.playOnLoad = false;
particles.autoRemoveOnFinish = false;
particles.spriteFrame = frame;
particles.duration = -1;
particles.positionType = cc.ParticleSystem.PositionType.FREE;
particles.emitterMode = cc.ParticleSystem.EmitterMode.GRAVITY;
particles.gravity = cc.v2(0, 0);
particles.sourcePos = cc.v2(0, 0);
particles.angle = 0;
particles.angleVar = 360;
particles.radialAccel = particles.radialAccelVar = 0;
particles.tangentialAccel = particles.tangentialAccelVar = 0;
particles.startSpin = particles.endSpin = 0;
particles.startSpinVar = particles.endSpinVar = 0;
particles.startColorVar = particles.endColorVar = cc.color(0, 0, 0, 0);
particles.srcBlendFactor = cc.macro.SRC_ALPHA;
particles.dstBlendFactor = cc.macro.ONE_MINUS_SRC_ALPHA;
this.node.addChild(node);
return particles;
}
setProgress(progress: number): void {
// 只改变新发射粒子的颜色,已经发射的粒子在原位置保留颜色并渐隐。
const palette = [[255, 72, 93], [255, 166, 57], [255, 232, 80], [77, 220, 112],
[57, 215, 245], [85, 130, 255], [187, 99, 244], [255, 72, 93]];
const phase = ((progress * 2) % 1) * (palette.length - 1);
const index = Math.floor(phase);
const fraction = phase - index;
const rgb = palette[index].map((value, channel) => Math.round(value
+ (palette[index + 1][channel] - value) * fraction));
this.mist.startColor = cc.color(rgb[0], rgb[1], rgb[2], 185);
this.mist.endColor = cc.color(rgb[0], rgb[1], rgb[2], 0);
}
stop(): void {
this.stopped = true;
this.head.active = false;
this.mist.stopSystem();
this.stars.stopSystem();
}
update(paused: boolean): boolean {
// 禁用节点同时停止粒子模拟,恢复时不 reset,保留既有尾迹。
this.node.active = !paused;
if (this.stopped && !paused && this.mist.particleCount === 0 && this.stars.particleCount === 0) {
this.destroy();
return false;
}
return true;
}
destroy(): void {
this.node.active = false;
this.node.destroy();
this.frames.forEach(frame => frame.destroy());
this.textures.forEach(texture => texture.destroy());
this.frames = [];
this.textures = [];
}
private texture(star: boolean): cc.SpriteFrame {
const size = 64;
const pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const nx = (x + 0.5 - size / 2) / (size / 2);
const ny = (y + 0.5 - size / 2) / (size / 2);
const radius = Math.sqrt(nx * nx + ny * ny);
const glow = Math.max(0, 1 - radius) * Math.exp(-3 * radius * radius);
const rays = Math.max(0, Math.min(1, (1 - Math.sqrt(Math.abs(nx)) - Math.sqrt(Math.abs(ny))) * 16));
const alpha = star ? Math.max(rays, glow * 0.2) : glow;
const offset = (y * size + x) * 4;
pixels[offset] = pixels[offset + 1] = pixels[offset + 2] = 255;
pixels[offset + 3] = Math.round(alpha * 255);
}
const texture = new cc.Texture2D();
texture.initWithData(pixels, cc.Texture2D.PixelFormat.RGBA8888, size, size);
const frame = new cc.SpriteFrame(texture);
this.textures.push(texture);
this.frames.push(frame);
return frame;
}
}

View File

@ -0,0 +1,10 @@
{
"ver": "1.1.0",
"uuid": "ef28cfd2-02d6-442e-9e5f-9d7c004f3474",
"importer": "typescript",
"isPlugin": false,
"loadPluginInWeb": true,
"loadPluginInNative": true,
"loadPluginInEditor": false,
"subMetas": {}
}

View File

@ -0,0 +1,53 @@
const { ccclass, property } = cc._decorator;
/** 两张镜像彩虹沿旋转后的纵轴循环,完全离开遮罩后接到另一张后面。 */
@ccclass
export default class RainbowScroll extends cc.Component {
@property(cc.Node)
color1: cc.Node = null;
@property(cc.Node)
color2: cc.Node = null;
@property({ tooltip: "彩虹向左上滚动的速度(每秒像素)", min: 0 })
speed: number = 60;
private colors: cc.Node[] = [];
private direction = cc.v2(0, 0);
private exits: number[] = [];
private loopLength = 0;
onLoad(): void {
// 预制体重存后引用可能为空,按既有节点层级恢复。
const mask = this.node.getChildByName("mask");
this.color1 = this.color1 || mask.getChildByName("color1");
this.color2 = this.color2 || mask.getChildByName("color2");
this.colors = [this.color1, this.color2];
const radians = this.color1.angle * Math.PI / 180;
this.direction = cc.v2(-Math.sin(radians), Math.cos(radians));
const centers = this.colors.map(node => mask.convertToNodeSpaceAR(node.convertToWorldSpaceAR(
cc.v2((0.5 - node.anchorX) * node.width, (0.5 - node.anchorY) * node.height))));
const offsets = centers.map((center, i) => cc.v2(center.x - this.colors[i].x, center.y - this.colors[i].y));
const lengths = this.colors.map(node => node.height * Math.abs(node.scaleY));
this.loopLength = lengths[0] + lengths[1];
const left = -mask.width * mask.anchorX;
const bottom = -mask.height * mask.anchorY;
const maskEnd = Math.max(left * this.direction.x, (left + mask.width) * this.direction.x)
+ Math.max(bottom * this.direction.y, (bottom + mask.height) * this.direction.y);
this.exits = offsets.map((offset, i) => maskEnd + lengths[i] / 2
- offset.x * this.direction.x - offset.y * this.direction.y);
}
update(dt: number): void {
const distance = (this.speed * dt) % this.loopLength;
this.colors.forEach((node, i) => {
node.x += this.direction.x * distance;
node.y += this.direction.y * distance;
if (node.x * this.direction.x + node.y * this.direction.y > this.exits[i]) {
node.x -= this.direction.x * this.loopLength;
node.y -= this.direction.y * this.loopLength;
}
});
}
}

View File

@ -0,0 +1,10 @@
{
"ver": "1.1.0",
"uuid": "dc21a4b5-f370-4bcc-9653-0db6305573a8",
"importer": "typescript",
"isPlugin": false,
"loadPluginInWeb": true,
"loadPluginInNative": true,
"loadPluginInEditor": false,
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "b5a2138e-afd2-4ee4-93b8-c62c8e43ea71",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "1339c469-1a46-4ab3-bbc9-80ff25a92373",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "f3c80619-7ab4-4034-94af-2e183afdbc76",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -1,13 +0,0 @@
{
"ver": "1.1.3",
"uuid": "2e586a33-44c3-470f-8458-36b0f8526927",
"importer": "folder",
"isBundle": false,
"bundleName": "",
"priority": 1,
"compressionType": {},
"optimizeHotUpdate": {},
"inlineSpriteFrames": {},
"isRemoteBundle": {},
"subMetas": {}
}

View File

@ -21,13 +21,17 @@
"__id__": 2
},
{
"__id__": 5
"__id__": 11
}
],
"_active": true,
"_components": [],
"_components": [
{
"__id__": 14
}
],
"_prefab": {
"__id__": 11
"__id__": 15
},
"_opacity": 255,
"_color": {
@ -76,113 +80,6 @@
"groupIndex": 0,
"_id": ""
},
{
"__type__": "cc.Node",
"_name": "bg",
"_objFlags": 0,
"_parent": {
"__id__": 1
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 3
}
],
"_prefab": {
"__id__": 4
},
"_opacity": 255,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": 255
},
"_contentSize": {
"__type__": "cc.Size",
"width": 122,
"height": 129
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 1,
"y": 0
},
"_trs": {
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
-0.448,
-7.608,
0,
0,
0,
0,
1,
1,
1,
1
]
},
"_eulerAngles": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_skewX": 0,
"_skewY": 0,
"_is3DNode": false,
"_groupIndex": 0,
"groupIndex": 0,
"_id": ""
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 2
},
"_enabled": true,
"_materials": [
{
"__uuid__": "eca5d2f2-8ef6-41c2-bbe6-f9c79d09c432"
}
],
"_srcBlendFactor": 770,
"_dstBlendFactor": 771,
"_spriteFrame": {
"__uuid__": "bf642998-76fc-40cb-a174-32ddf494bcdd"
},
"_type": 0,
"_sizeMode": 1,
"_fillType": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "d5/XKhIrNFkqj07F0HWvmm",
"sync": false
},
{
"__type__": "cc.Node",
"_name": "mask",
@ -191,6 +88,9 @@
"__id__": 1
},
"_children": [
{
"__id__": 3
},
{
"__id__": 6
}
@ -253,11 +153,118 @@
},
{
"__type__": "cc.Node",
"_name": "New Sprite",
"_name": "color1",
"_objFlags": 0,
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 4
}
],
"_prefab": {
"__id__": 5
},
"_opacity": 255,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": 255
},
"_contentSize": {
"__type__": "cc.Size",
"width": 240,
"height": 170
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 1,
"y": 0
},
"_trs": {
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
19.327,
136.339,
0,
0,
0,
0.3826834323650898,
0.9238795325112867,
1,
1,
1
]
},
"_eulerAngles": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 45
},
"_skewX": 0,
"_skewY": 0,
"_is3DNode": false,
"_groupIndex": 0,
"groupIndex": 0,
"_id": ""
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 3
},
"_enabled": true,
"_materials": [
{
"__uuid__": "eca5d2f2-8ef6-41c2-bbe6-f9c79d09c432"
}
],
"_srcBlendFactor": 770,
"_dstBlendFactor": 771,
"_spriteFrame": {
"__uuid__": "ed9c85c6-2c98-456d-b747-5da1bfe909ae"
},
"_type": 0,
"_sizeMode": 1,
"_fillType": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "d5L0nbgkpD4ZyDf+Us5amd",
"sync": false
},
{
"__type__": "cc.Node",
"_name": "color2",
"_objFlags": 0,
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
@ -290,15 +297,15 @@
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
81.431,
74.235,
-149.165,
-32.153,
0,
0,
0,
0.3826834323650898,
0.9238795325112867,
1,
1,
-1,
-1,
1
]
},
@ -355,7 +362,7 @@
"asset": {
"__id__": 0
},
"fileId": "d5L0nbgkpD4ZyDf+Us5amd",
"fileId": "faN8DteCxEmYpUHBBQ+W4v",
"sync": false
},
{
@ -363,7 +370,7 @@
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 5
"__id__": 2
},
"_enabled": true,
"_materials": [
@ -391,6 +398,130 @@
"fileId": "f1CTpBauNJgKypuXhaOEje",
"sync": false
},
{
"__type__": "cc.Node",
"_name": "bg",
"_objFlags": 0,
"_parent": {
"__id__": 1
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 12
}
],
"_prefab": {
"__id__": 13
},
"_opacity": 255,
"_color": {
"__type__": "cc.Color",
"r": 255,
"g": 255,
"b": 255,
"a": 255
},
"_contentSize": {
"__type__": "cc.Size",
"width": 122,
"height": 129
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 1,
"y": 0
},
"_trs": {
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
-0.448,
-7.608,
0,
0,
0,
0,
1,
1,
1,
1
]
},
"_eulerAngles": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_skewX": 0,
"_skewY": 0,
"_is3DNode": false,
"_groupIndex": 0,
"groupIndex": 0,
"_id": ""
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 11
},
"_enabled": true,
"_materials": [
{
"__uuid__": "eca5d2f2-8ef6-41c2-bbe6-f9c79d09c432"
}
],
"_srcBlendFactor": 770,
"_dstBlendFactor": 771,
"_spriteFrame": {
"__uuid__": "bf642998-76fc-40cb-a174-32ddf494bcdd"
},
"_type": 0,
"_sizeMode": 1,
"_fillType": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "d5/XKhIrNFkqj07F0HWvmm",
"sync": false
},
{
"__type__": "dc21aS183BLzJZTDbYwVXOo",
"_name": "",
"_objFlags": 0,
"node": {
"__id__": 1
},
"_enabled": true,
"color1": {
"__id__": 3
},
"color2": {
"__id__": 6
},
"speed": 60,
"_id": ""
},
{
"__type__": "cc.PrefabInfo",
"root": {

View File

@ -138,7 +138,7 @@ class Node {
get position() { return v2(this.x, this.y); }
setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }
getContentSize() { return { width: this.width, height: this.height }; }
setContentSize(size) { Object.assign(this, size); }
setContentSize(size, height) { Object.assign(this, typeof size === 'number' ? { width: size, height } : size); }
getAnchorPoint() { return v2(this.anchorX, this.anchorY); }
setAnchorPoint(p) { this.anchorX = p.x; this.anchorY = p.y; }
getChildByName(name) { return this.children.find(n => n.name === name); }
@ -153,10 +153,28 @@ class Node {
convertToWorldSpaceAR(p) { return p.clone(); }
}
class Sprite { constructor() { this.fillRange = 1; } }
Sprite.SizeMode = { CUSTOM: 0 };
class Texture2D {
initWithData(pixels, format, width, height) { Object.assign(this, { pixels, format, width, height }); }
destroy() { this.destroyed = true; }
}
Texture2D.PixelFormat = { RGBA8888: 6 };
class SpriteFrame {
constructor(texture) { this.texture = texture; }
destroy() { this.destroyed = true; }
}
class ParticleSystem {
constructor() { this.resets = 0; this.particleCount = 0; }
resetSystem() { this.resets++; this.active = true; }
stopSystem() { this.active = false; }
}
ParticleSystem.PositionType = { FREE: 0 };
ParticleSystem.EmitterMode = { GRAVITY: 0 };
class PolygonCollider {}
const engine = { Node, Sprite, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b) => ({r,g,b}),
Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999 }, isValid: node => !!node && !node.destroyed,
const engine = { Node, Sprite, Texture2D, SpriteFrame, ParticleSystem, PolygonCollider, BlockInputEvents: class BlockInputEvents {}, v2, color: (r, g, b, a = 255) => ({r,g,b,a}),
Color: { WHITE: {} }, macro: { MAX_ZINDEX: 99999, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771 }, isValid: node => !!node && !node.destroyed,
game: { on() {}, off() {}, EVENT_HIDE: 'hide', EVENT_SHOW: 'show' }, find: () => new Node('Canvas') };
const trailModule = load('assets/Script/RainbowTrail.ts', engine);
function block(shape = 1, type = 0, info = {}) {
const node = new Node('block' + shape);
const b = { node, block_Info: { block: shape, type, color: 2, ...info }, type, color: 2, posX: 5, posY: 5,
@ -169,7 +187,7 @@ function block(shape = 1, type = 0, info = {}) {
function controllerFixture(blocks = [block(), block()]) {
const applied = [];
const Controller = load('assets/Script/RainbowStreak.ts', engine,
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => applied.push(args) } }).default;
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => applied.push(args) }, './RainbowTrail': trailModule }).default;
const map = { blocks: blocks.map(b => b.node), gameStart: true, scheduleCallback() {}, timeNumber: 100,
hasPendingSpawnGateWork: () => false, isPauseOpen: () => false, iceTrue: () => false, magicMask: { active: false } };
map.node = new Node('Map'); map.node.children = map.blocks;
@ -194,6 +212,53 @@ test('first press excludes the whole dragged group; 1.5 s flight keeps input loc
assert.equal(first.touchDelta.mag(), 0); assert.equal(first.isTouch, true);
});
test('rainbow trail pauses without restarting, fades after arrival even when bottle stops, and releases textures', () => {
const f = controllerFixture();
const parent = new Node('effects');
f.map.magics = new Node('magics'); parent.addChild(f.map.magics);
f.map.mapBlocksWall = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => {
const cell = new Node('cell'); parent.addChild(cell); return cell;
}));
f.controller.onPress(f.blocks[0], { getLocation: () => v2() });
const trail = f.controller.flight;
const particles = trail.node.children.map(node => node.getComponent(ParticleSystem)).filter(Boolean);
const frames = particles.map(p => p.spriteFrame);
particles.forEach(p => p.particleCount = 8);
f.controller.update(.4);
const position = plain(trail.node.position);
f.map.pause = true; f.controller.update(10);
assert.equal(f.applied.length, 0); assert.equal(f.controller.shield.active, true);
assert.deepEqual(plain(trail.node.position), position);
assert.equal(trail.node.active, false);
f.map.pause = false; f.controller.update(1.1);
assert.equal(f.applied.length, 1); assert.equal(f.controller.shield, null);
assert.equal(trail.node.active, true); assert.equal(trail.node.destroyed, undefined);
assert.ok(particles.every(p => !p.active && p.resets === 1));
assert.equal(trail.node.getChildByName('rainbowHead').active, false);
f.controller.energy.stopped = true;
f.controller.hide(); f.controller.update(10);
assert.equal(trail.node.destroyed, undefined);
f.controller.show(); f.controller.update(1);
assert.equal(trail.node.destroyed, undefined);
particles.forEach(p => p.particleCount = 0);
f.controller.update(.1);
assert.equal(trail.node.destroyed, true);
assert.ok(frames.every(frame => frame.destroyed && frame.texture.destroyed));
assert.equal(f.controller.fadingFlights.length, 0);
});
test('scene exit disposes an in-flight rainbow effect and input shield immediately', () => {
const f = controllerFixture();
f.controller.onPress(f.blocks[0], { getLocation: () => v2() });
const trail = f.controller.flight = new trailModule.default(new Node('effects'), rules.RAINBOW_CAST_SECONDS);
const shield = f.controller.shield;
const textures = trail.node.children.map(n => n.getComponent(ParticleSystem)).filter(Boolean).map(p => p.spriteFrame.texture);
f.controller.dispose();
assert.equal(f.applied.length, 0); assert.equal(shield.active, false); assert.equal(shield.destroyed, true);
assert.equal(trail.node.destroyed, true); assert.ok(textures.every(texture => texture.destroyed));
assert.equal(f.controller.casting, false);
});
test('release during flight settles once; lost target consumes cast; only dragged group consumes without locking', () => {
const f = controllerFixture(); const first = f.blocks[0];
first.isTouch = true; first.node.parent = new Node(); let ends = 0;