更新shader动画,更新不可移动机制 十连胜

This commit is contained in:
WIN-GKKD951VVMJ\Administrator 2026-09-15 20:14:07 +08:00
parent 59bf39cd4e
commit efdbceb351
12 changed files with 428 additions and 74 deletions

View File

@ -3715,7 +3715,7 @@
"__type__": "TypedArray",
"ctor": "Float64Array",
"array": [
-13.925,
6.12,
129.696,
0,
0,

View File

@ -1469,7 +1469,7 @@ export default class Block extends cc.Component {
// - this.relative_Position:记录手指相对于方块中心的位置
// ============================================
touchStart(event) {
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.casting) return false;
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.isInputLocked(this)) return false;
if (this.spawnLocked || this.over || MapConroler._instance.gameOver || MapConroler._instance.touchIng) return;
// 返回世界坐标
let touchLoc = event.getLocation();
@ -1611,7 +1611,7 @@ export default class Block extends cc.Component {
}
}
}
if (MapConroler._instance.rainbowStreak) MapConroler._instance.rainbowStreak.onPress(this, event);
if (MapConroler._instance.rainbowStreak) MapConroler._instance.rainbowStreak.onPress(this);
return true;
}
else {
@ -1652,10 +1652,7 @@ export default class Block extends cc.Component {
// 7. 如果是粘合块,还要处理被粘合块的落下
// ============================================
touchEnd(event) {
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.casting) {
MapConroler._instance.rainbowStreak.capture(this, event, true);
return;
}
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.isInputLocked(this)) return;
if (this.spawnLocked) return;
MapConroler._instance.touchIng = false;
if (MapConroler._instance.gameOver) {
@ -1789,10 +1786,7 @@ export default class Block extends cc.Component {
// - 移动速度限制防止方块穿透障碍物
// ============================================
touchMove(event: cc.Event.EventTouch) {
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.casting) {
MapConroler._instance.rainbowStreak.capture(this, event, false);
return;
}
if (MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.isInputLocked(this)) return;
if (this.spawnLocked) return;
if (MapConroler._instance.gameOver) {
if (this.isTouch == true) {
@ -3017,7 +3011,7 @@ export default class Block extends cc.Component {
// - checkCollision标记是否正在进行碰撞检测
// ============================================
update(dt: number) {
if (MapConroler._instance && MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.casting) return;
if (MapConroler._instance && MapConroler._instance.rainbowStreak && MapConroler._instance.rainbowStreak.isInputLocked(this)) return;
if (this.spawnLocked) return;
if (this.isTouch && this.touchDelta.mag() > 0) {
//this.moveLeft = this.moveRight = this.moveUp = this.moveDown = true;

View File

@ -5789,7 +5789,6 @@ export default class MapConroler extends cc.Component {
}
//使用时间道具
useTimeProp(event?: cc.Event, customEventData?: string) {
if (this.rainbowStreak && this.rainbowStreak.casting) return;
if (this.gameOver == true || this.gameWin == true) {
return;
}
@ -6045,7 +6044,6 @@ export default class MapConroler extends cc.Component {
// 效果:进入锤子模式,玩家点击方块会直接消除该方块
// ============================================
useHammer(event?: cc.Event, customEventData?: string) {
if (this.rainbowStreak && this.rainbowStreak.casting) return;
if (this.gameOver == true || this.gameWin == true) {
return;
}
@ -6255,7 +6253,6 @@ export default class MapConroler extends cc.Component {
// 效果:进入魔法棒模式,自动寻找并消除两个相邻的同色方块
// ============================================
useMagic(event?: cc.Event, customEventData?: string) {
if (this.rainbowStreak && this.rainbowStreak.casting) return;
if (this.gameOver == true || this.gameWin == true) {
return;
}

View File

@ -0,0 +1,51 @@
const { ccclass, property, requireComponent } = cc._decorator;
/** 保留 fillRange 作为能量读数,改由 Shader 绘制起伏液面。 */
@ccclass
@requireComponent(cc.Sprite)
export default class RainbowBottleWater extends cc.Component {
@property({ tooltip: "液面波浪高度(像素)", min: 0 })
waveHeight: number = 4;
@property({ tooltip: "液体流动速度", min: 0 })
waveSpeed: number = 1;
private sprite: cc.Sprite = null;
private material: cc.Material = null;
private elapsed = 0;
onLoad(): void {
this.sprite = this.getComponent(cc.Sprite);
const frame = this.sprite.spriteFrame;
const texture = frame.getTexture();
// 在异步加载材质前禁止打图集,保持液面 Shader 的 UV 范围稳定。
texture.packable = false;
cc.resources.load("shader/rainbow_bottle", cc.Material, (error: Error, material: cc.Material) => {
if (!cc.isValid(this, true)) return;
if (error) {
cc.warn("彩虹瓶子波浪材质加载失败", error);
return;
}
this.sprite.setMaterial(0, material);
this.material = this.sprite.getMaterial(0);
if (cc.sys.glExtension("OES_standard_derivatives")) {
this.material.define("CC_SUPPORT_standard_derivatives", true);
}
const rect = frame.getRect();
const rotated = frame.isRotated();
this.material.setProperty("uvRect", [rect.x / texture.width, rect.y / texture.height,
(rotated ? rect.height : rect.width) / texture.width,
(rotated ? rect.width : rect.height) / texture.height]);
this.material.setProperty("uvRotated", rotated ? 1 : 0);
this.sprite.type = cc.Sprite.Type.SIMPLE;
this.lateUpdate(0);
});
}
lateUpdate(dt: number): void {
if (!this.material) return;
this.elapsed += dt * this.waveSpeed;
this.material.setProperty("water", [this.sprite.fillRange, this.elapsed,
this.waveHeight / this.node.height, 1 / this.node.height]);
}
}

View File

@ -0,0 +1,10 @@
{
"ver": "1.1.0",
"uuid": "ce4d7f7c-1f4e-4d98-bd0e-efcb6144d1f6",
"importer": "typescript",
"isPlugin": false,
"loadPluginInWeb": true,
"loadPluginInNative": true,
"loadPluginInEditor": false,
"subMetas": {}
}

View File

@ -1,5 +1,6 @@
import { applyRainbowCut } from "./RainbowCut";
import RainbowTrail from "./RainbowTrail";
import RainbowBottleWater from "./RainbowBottleWater";
import { chooseRainbowTarget, rainbowPriority, RainbowEnergy, RAINBOW_CUTS, RAINBOW_CAPACITY, RAINBOW_CAST_SECONDS } from "./RainbowRules";
/** 每关独立持有;原地复活不重建,不占用暂停或道具的状态。 */
@ -10,22 +11,22 @@ export default class RainbowStreak {
private hidden = false;
private credited = new Set<cc.Node>();
private bottle: cc.Node;
private shield: cc.Node = null;
private lockedNodes = new Set<cc.Node>();
private flight: RainbowTrail = null;
private fadingFlights: RainbowTrail[] = [];
private elapsed = 0;
private selection: any = null;
private from: cc.Vec2;
private to: cc.Vec2;
private finger: any = null;
private location: cc.Vec2 = null;
private released = false;
private hide = () => { this.hidden = true; };
private show = () => { this.hidden = false; };
constructor(private map: any, bottle: cc.Node) {
this.bottle = bottle;
if (bottle) bottle.active = true;
if (bottle) {
bottle.active = true;
if (!bottle.getComponent(RainbowBottleWater)) bottle.addComponent(RainbowBottleWater);
}
cc.game.on(cc.game.EVENT_HIDE, this.hide);
cc.game.on(cc.game.EVENT_SHOW, this.show);
this.refreshAvailability();
@ -70,7 +71,7 @@ export default class RainbowStreak {
});
}
onPress(block: any, event: any): void {
onPress(block: any): void {
if (!this.first || !this.counting()) return;
this.first = false;
const excluded = new Set<any>([block.node]);
@ -78,11 +79,7 @@ export default class RainbowStreak {
this.addLinked(block.node, excluded);
const candidates = this.refreshAvailability().filter(candidate => !excluded.has(candidate.block.node));
if (!this.energy.consume()) return;
this.finger = block;
this.location = event.getLocation().clone();
this.released = false;
this.begin(candidates);
if (!this.casting) this.finger = null;
this.render();
}
@ -90,6 +87,12 @@ export default class RainbowStreak {
const block = node.getComponent("Block");
const linked = (block.teamBlocks || []).slice();
if ((block.type === 9 || block.type === 1 || block.type === 10) && block.block_Info.node) linked.push(block.block_Info.node);
// 个别地板先解除时,队友仍可能保留指向它的联动关系,也要一起排除或锁住。
this.map.node.children.forEach(member => {
const other = cc.isValid(member, true) && member.getComponent("Block");
if (other && ((other.teamBlocks || []).indexOf(node) >= 0
|| ((other.type === 9 || other.type === 1 || other.type === 10) && other.block_Info.node === node))) linked.push(member);
});
linked.forEach(member => {
if (cc.isValid(member, true) && !excluded.has(member)) {
excluded.add(member);
@ -98,11 +101,8 @@ export default class RainbowStreak {
});
}
capture(block: any, event: any, released: boolean): void {
if (this.finger !== block) return;
this.location = event.getLocation().clone();
this.released = this.released || released;
block.touchDelta = cc.v2(0, 0);
isInputLocked(block: any): boolean {
return this.lockedNodes.has(block.node);
}
eliminated(node: cc.Node): void {
@ -155,12 +155,9 @@ export default class RainbowStreak {
this.selection = { block, cut: choice.cut, shape: block.block_Info.block, x: block.posX, y: block.posY };
this.casting = true;
this.elapsed = 0;
const canvas = cc.find("Canvas");
this.shield = new cc.Node("rainbowIntercept");
this.shield.setContentSize(canvas.getContentSize());
this.shield.addComponent(cc.BlockInputEvents);
canvas.addChild(this.shield);
this.shield.zIndex = cc.macro.MAX_ZINDEX;
// 只叠加本次施法的输入限制,不改地板、冻结等原有移动状态。
this.lockedNodes.add(block.node);
this.addLinked(block.node, this.lockedNodes);
if (this.map.magics && this.bottle) {
const parent = this.map.magics.parent;
this.from = parent.convertToNodeSpaceAR(this.bottle.convertToWorldSpaceAR(cc.v2(0, 0)));
@ -184,21 +181,12 @@ export default class RainbowStreak {
} finally {
this.casting = false;
this.selection = null;
if (this.shield) { this.shield.active = false; this.shield.destroy(); this.shield = null; }
this.lockedNodes.clear();
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) {
const point = this.location.clone();
const local = block.node.parent.convertToNodeSpaceAR(point);
block.relative_Position = cc.v2(block.node.x - local.x, block.node.y - local.y);
block.touchDelta = cc.v2(0, 0);
if (this.released) block.touchEnd({ getLocation: () => point });
}
}
}

View File

@ -0,0 +1,107 @@
CCEffect %{
techniques:
- passes:
- vert: rainbow-water-vs
frag: rainbow-water-fs
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
rasterizerState:
cullMode: none
depthStencilState:
depthTest: false
depthWrite: false
properties:
texture: { value: white }
uvRect: { value: [0, 0, 1, 1] }
uvRotated: { value: 0 }
water: { value: [1, 0, 0.024, 0.006] }
}%
CCProgram rainbow-water-vs %{
precision highp float;
#include <cc-global>
#include <cc-local>
in vec3 a_position;
in vec4 a_color;
in vec2 a_uv0;
out vec4 v_color;
out vec2 v_uv0;
void main () {
vec4 pos = vec4(a_position, 1.0);
#if CC_USE_MODEL
pos = cc_matViewProj * cc_matWorld * pos;
#else
pos = cc_matViewProj * pos;
#endif
gl_Position = pos;
v_color = a_color;
v_uv0 = a_uv0;
}
}%
CCProgram rainbow-water-fs %{
#if CC_SUPPORT_standard_derivatives
#extension GL_OES_standard_derivatives : enable
#endif
precision highp float;
#include <texture>
in vec4 v_color;
in vec2 v_uv0;
uniform sampler2D texture;
uniform WaterProperties {
vec4 uvRect;
vec4 water;
float uvRotated;
};
void main () {
vec2 uv = (v_uv0 - uvRect.xy) / max(uvRect.zw, vec2(0.00001));
if (uvRotated > 0.5) uv = vec2(uv.y, 1.0 - uv.x);
vec4 base = vec4(1.0);
{
CCTexture(texture, v_uv0, base);
}
// 固定波形从左向右平移,瓶内颜色跟随同一波列,不在中途叠出新的凸起。
float phase = uv.x * 7.5 - water.y * 1.8;
float envelope = 4.0 * uv.y * (1.0 - uv.y);
vec2 drift = vec2(0.0, 0.75 * sin(phase + uv.y * 3.0));
vec2 flowingUV = clamp(uv + drift * water.z * envelope, vec2(0.001), vec2(0.999));
if (uvRotated > 0.5) flowingUV = vec2(1.0 - flowingUV.y, flowingUV.x);
vec4 flowing = vec4(1.0);
{
CCTexture(texture, uvRect.xy + flowingUV * uvRect.zw, flowing);
}
vec4 color = vec4(mix(base.rgb, flowing.rgb, flowing.a), base.a);
// 液面沿同一方向连续推进;空瓶不残留,满瓶保持完整。
float amplitude = water.z * smoothstep(0.0, 0.08, water.x)
* smoothstep(0.0, 0.08, 1.0 - water.x);
float wave = amplitude * sin(phase);
float surface = water.x + wave;
float height = 1.0 - uv.y;
float distanceToSurface = height - surface;
float feather = water.w;
#if CC_SUPPORT_standard_derivatives
// 按实际屏幕像素抗锯齿,缩小瓶子后液面仍能平滑跨过像素边界。
feather = max(0.75 * fwidth(distanceToSurface), 0.00001);
#endif
if (water.x <= 0.0) {
color.a = 0.0;
} else if (water.x < 1.0) {
color.a *= 1.0 - smoothstep(-feather, feather, distanceToSurface);
float rim = 1.0 - smoothstep(0.0, feather, abs(distanceToSurface));
color.rgb = mix(color.rgb, vec3(1.0, 0.98, 0.88), rim * 0.08);
}
color *= v_color;
#if USE_BGRA
gl_FragColor = color.bgra;
#else
gl_FragColor = color;
#endif
}
}%

View File

@ -0,0 +1,18 @@
{
"ver": "1.0.27",
"uuid": "a6c19db7-43a2-469e-8162-b76b5c2d7b0f",
"importer": "effect",
"compiledShaders": [
{
"glsl1": {
"vert": "\nprecision highp float;\nuniform mat4 cc_matViewProj;\nuniform mat4 cc_matWorld;\nattribute vec3 a_position;\nattribute vec4 a_color;\nattribute vec2 a_uv0;\nvarying vec4 v_color;\nvarying vec2 v_uv0;\nvoid main () {\n vec4 pos = vec4(a_position, 1.0);\n #if CC_USE_MODEL\n pos = cc_matViewProj * cc_matWorld * pos;\n #else\n pos = cc_matViewProj * pos;\n #endif\n gl_Position = pos;\n v_color = a_color;\n v_uv0 = a_uv0;\n}",
"frag": "\n#if CC_SUPPORT_standard_derivatives\n #extension GL_OES_standard_derivatives : enable\n#endif\nprecision highp float;\nvarying vec4 v_color;\nvarying vec2 v_uv0;\nuniform sampler2D texture;\nuniform vec4 uvRect;\nuniform vec4 water;\nuniform float uvRotated;\nvoid main () {\n vec2 uv = (v_uv0 - uvRect.xy) / max(uvRect.zw, vec2(0.00001));\n if (uvRotated > 0.5) uv = vec2(uv.y, 1.0 - uv.x);\n vec4 base = vec4(1.0);\n {\n vec4 texture_tmp = texture2D(texture, v_uv0);\n #if CC_USE_ALPHA_ATLAS_texture\n texture_tmp.a *= texture2D(texture, v_uv0 + vec2(0, 0.5)).r;\n #endif\n #if INPUT_IS_GAMMA\n base.rgb *= (texture_tmp.rgb * texture_tmp.rgb);\n base.a *= texture_tmp.a;\n #else\n base *= texture_tmp;\n #endif\n }\n float phase = uv.x * 7.5 - water.y * 1.8;\n float envelope = 4.0 * uv.y * (1.0 - uv.y);\n vec2 drift = vec2(0.0, 0.75 * sin(phase + uv.y * 3.0));\n vec2 flowingUV = clamp(uv + drift * water.z * envelope, vec2(0.001), vec2(0.999));\n if (uvRotated > 0.5) flowingUV = vec2(1.0 - flowingUV.y, flowingUV.x);\n vec4 flowing = vec4(1.0);\n {\n vec4 texture_tmp = texture2D(texture, uvRect.xy + flowingUV * uvRect.zw);\n #if CC_USE_ALPHA_ATLAS_texture\n texture_tmp.a *= texture2D(texture, uvRect.xy + flowingUV * uvRect.zw + vec2(0, 0.5)).r;\n #endif\n #if INPUT_IS_GAMMA\n flowing.rgb *= (texture_tmp.rgb * texture_tmp.rgb);\n flowing.a *= texture_tmp.a;\n #else\n flowing *= texture_tmp;\n #endif\n }\n vec4 color = vec4(mix(base.rgb, flowing.rgb, flowing.a), base.a);\n float amplitude = water.z * smoothstep(0.0, 0.08, water.x)\n * smoothstep(0.0, 0.08, 1.0 - water.x);\n float wave = amplitude * sin(phase);\n float surface = water.x + wave;\n float height = 1.0 - uv.y;\n float distanceToSurface = height - surface;\n float feather = water.w;\n #if CC_SUPPORT_standard_derivatives\n feather = max(0.75 * fwidth(distanceToSurface), 0.00001);\n #endif\n if (water.x <= 0.0) {\n color.a = 0.0;\n } else if (water.x < 1.0) {\n color.a *= 1.0 - smoothstep(-feather, feather, distanceToSurface);\n float rim = 1.0 - smoothstep(0.0, feather, abs(distanceToSurface));\n color.rgb = mix(color.rgb, vec3(1.0, 0.98, 0.88), rim * 0.08);\n }\n color *= v_color;\n #if USE_BGRA\n gl_FragColor = color.bgra;\n #else\n gl_FragColor = color;\n #endif\n}"
},
"glsl3": {
"vert": "\nprecision highp float;\nuniform CCGlobal {\n mat4 cc_matView;\n mat4 cc_matViewInv;\n mat4 cc_matProj;\n mat4 cc_matProjInv;\n mat4 cc_matViewProj;\n mat4 cc_matViewProjInv;\n vec4 cc_cameraPos;\n vec4 cc_time;\n mediump vec4 cc_screenSize;\n mediump vec4 cc_screenScale;\n};\nuniform CCLocal {\n mat4 cc_matWorld;\n mat4 cc_matWorldIT;\n};\nin vec3 a_position;\nin vec4 a_color;\nin vec2 a_uv0;\nout vec4 v_color;\nout vec2 v_uv0;\nvoid main () {\n vec4 pos = vec4(a_position, 1.0);\n #if CC_USE_MODEL\n pos = cc_matViewProj * cc_matWorld * pos;\n #else\n pos = cc_matViewProj * pos;\n #endif\n gl_Position = pos;\n v_color = a_color;\n v_uv0 = a_uv0;\n}",
"frag": "\n#if CC_SUPPORT_standard_derivatives\n #extension GL_OES_standard_derivatives : enable\n#endif\nprecision highp float;\nin vec4 v_color;\nin vec2 v_uv0;\nuniform sampler2D texture;\nuniform WaterProperties {\n vec4 uvRect;\n vec4 water;\n float uvRotated;\n};\nvoid main () {\n vec2 uv = (v_uv0 - uvRect.xy) / max(uvRect.zw, vec2(0.00001));\n if (uvRotated > 0.5) uv = vec2(uv.y, 1.0 - uv.x);\n vec4 base = vec4(1.0);\n {\n vec4 texture_tmp = texture(texture, v_uv0);\n #if CC_USE_ALPHA_ATLAS_texture\n texture_tmp.a *= texture(texture, v_uv0 + vec2(0, 0.5)).r;\n #endif\n #if INPUT_IS_GAMMA\n base.rgb *= (texture_tmp.rgb * texture_tmp.rgb);\n base.a *= texture_tmp.a;\n #else\n base *= texture_tmp;\n #endif\n }\n float phase = uv.x * 7.5 - water.y * 1.8;\n float envelope = 4.0 * uv.y * (1.0 - uv.y);\n vec2 drift = vec2(0.0, 0.75 * sin(phase + uv.y * 3.0));\n vec2 flowingUV = clamp(uv + drift * water.z * envelope, vec2(0.001), vec2(0.999));\n if (uvRotated > 0.5) flowingUV = vec2(1.0 - flowingUV.y, flowingUV.x);\n vec4 flowing = vec4(1.0);\n {\n vec4 texture_tmp = texture(texture, uvRect.xy + flowingUV * uvRect.zw);\n #if CC_USE_ALPHA_ATLAS_texture\n texture_tmp.a *= texture(texture, uvRect.xy + flowingUV * uvRect.zw + vec2(0, 0.5)).r;\n #endif\n #if INPUT_IS_GAMMA\n flowing.rgb *= (texture_tmp.rgb * texture_tmp.rgb);\n flowing.a *= texture_tmp.a;\n #else\n flowing *= texture_tmp;\n #endif\n }\n vec4 color = vec4(mix(base.rgb, flowing.rgb, flowing.a), base.a);\n float amplitude = water.z * smoothstep(0.0, 0.08, water.x)\n * smoothstep(0.0, 0.08, 1.0 - water.x);\n float wave = amplitude * sin(phase);\n float surface = water.x + wave;\n float height = 1.0 - uv.y;\n float distanceToSurface = height - surface;\n float feather = water.w;\n #if CC_SUPPORT_standard_derivatives\n feather = max(0.75 * fwidth(distanceToSurface), 0.00001);\n #endif\n if (water.x <= 0.0) {\n color.a = 0.0;\n } else if (water.x < 1.0) {\n color.a *= 1.0 - smoothstep(-feather, feather, distanceToSurface);\n float rim = 1.0 - smoothstep(0.0, feather, abs(distanceToSurface));\n color.rgb = mix(color.rgb, vec3(1.0, 0.98, 0.88), rim * 0.08);\n }\n color *= v_color;\n #if USE_BGRA\n gl_FragColor = color.bgra;\n #else\n gl_FragColor = color;\n #endif\n}"
}
}
],
"subMetas": {}
}

View File

@ -0,0 +1,16 @@
{
"__type__": "cc.Material",
"_name": "rainbow_bottle",
"_objFlags": 0,
"_native": "",
"_effectAsset": {
"__uuid__": "a6c19db7-43a2-469e-8162-b76b5c2d7b0f"
},
"_techniqueIndex": 0,
"_techniqueData": {
"0": {
"defines": {},
"props": {}
}
}
}

View File

@ -0,0 +1,7 @@
{
"ver": "1.0.5",
"uuid": "a8764ad0-ec22-4e0f-8a24-267e49efcd8d",
"importer": "material",
"dataAsSubAsset": null,
"subMetas": {}
}

View File

@ -1,5 +1,5 @@
{
"last-module-event-record-time": 1788866731401,
"last-module-event-record-time": 1789474233869,
"group-list": [
"default",
"Map"

View File

@ -184,10 +184,11 @@ function block(shape = 1, type = 0, info = {}) {
initBlocks.call(b); node.components.Block = b;
return b;
}
function controllerFixture(blocks = [block(), block()]) {
function controllerFixture(blocks = [block(), block()], applyCut = () => {}) {
const applied = [];
const Controller = load('assets/Script/RainbowStreak.ts', engine,
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => applied.push(args) }, './RainbowTrail': trailModule }).default;
{ './RainbowRules': rules, './RainbowCut': { applyRainbowCut: (...args) => { applied.push(args); return applyCut(...args); } }, './RainbowTrail': trailModule,
'./RainbowBottleWater': { default: class RainbowBottleWater {} } }).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;
@ -196,20 +197,127 @@ function controllerFixture(blocks = [block(), block()]) {
return { controller, map, bottle, applied, blocks };
}
test('first press excludes the whole dragged group; 1.5 s flight keeps input locked until the cut', () => {
const f = controllerFixture(); const first = f.blocks[0];
first.isTouch = true; first.node.parent = new Node(); first.node.setPosition(30, 40);
f.map.touchIng = true;
f.controller.onPress(first, { getLocation: () => v2(30, 40) });
assert.equal(f.controller.casting, true); assert.equal(f.controller.selection.block, f.blocks[1]);
function inputMethods(f, blocks = f.blocks) {
const BlockType = { 普通块: 0, 叠加块下: 1, 钥匙块: 2, 上锁块: 3, 冻结块: 4, 粘合块: 9, 第二上锁块: 12, 单色地块: 22 };
const globals = { MapConroler: { _instance: f.map }, BlockType,
cc: { v2, Intersection: { pointInPolygon: () => true }, fx: { AudioManager: { _instance: { playEffect() {} } } } },
LQCollideSystem: { update_logic() {} } };
Object.assign(f.map, { rainbowStreak: f.controller, total_steps_count: 0, startUpdate() {}, changeRiseFall() {}, downDoor() {}, removeOneBlock() {} });
for (const b of blocks) {
b.node.parent = f.map.node;
Object.assign(b, { collider: { world: { points: [] } }, hit: b.hit || new Node('hit'), maxSpeed: 300,
setVibrate() {}, resetStartPos: () => false, blockFall() {}, touchDelta: v2() });
}
return Object.fromEntries(['touchStart', 'touchMove', 'touchEnd', 'update'].map(name => [name, method(blockFile, name, globals)]));
}
test('bottle wave attaches at runtime and keeps current energy while its material loads', () => {
assert.ok(controllerFixture().bottle.getComponent('RainbowBottleWater'));
const texture = { width: 225, height: 169, packable: true };
const uniforms = {};
const material = { setProperty: (name, value) => { uniforms[name] = plain(value); },
define: (name, value) => { uniforms[name] = value; } };
const sprite = { type: 3, fillRange: .2,
spriteFrame: { getTexture: () => texture, getRect: () => ({ x: 0, y: 0, width: 225, height: 169 }), isRotated: () => false },
setMaterial(index, value) { assert.equal(index, 0); this.material = value; }, getMaterial() { return this.material; } };
let finish;
const cc = { Sprite: { Type: { SIMPLE: 0 } }, Material: class Material {}, isValid: value => !value.destroyed,
sys: { glExtension: () => true },
resources: { load(url, type, callback) { assert.equal(url, 'shader/rainbow_bottle'); assert.equal(type, cc.Material); finish = callback; } } };
const file = 'assets/Script/RainbowBottleWater.ts';
const water = { getComponent: () => sprite, node: { height: 169 }, elapsed: 0, waveHeight: 4, waveSpeed: 1,
lateUpdate: method(file, 'lateUpdate') };
const onLoad = method(file, 'onLoad', { cc });
onLoad.call(water);
assert.equal(texture.packable, false);
assert.equal(sprite.type, 3, 'retain normal energy rendering until material is ready');
water.lateUpdate(1);
sprite.fillRange = .5;
finish(null, material);
assert.equal(sprite.type, 0);
assert.equal(uniforms.CC_SUPPORT_standard_derivatives, true);
assert.deepEqual(uniforms.uvRect, [0, 0, 1, 1]);
assert.equal(uniforms.water[0], .5, 'use latest energy, not value from load start');
water.lateUpdate(1.2);
assert.equal(uniforms.water[1], 1.2, 'wave advances while energy stays still');
assert.equal(sprite.fillRange, .5);
sprite.type = 3;
onLoad.call(water);
water.destroyed = true;
finish(null, material);
assert.equal(sprite.type, 3, 'ignore material completion after scene exit');
});
test('flight locks only its target; the first gesture moves and releases immediately, then the target unlocks', () => {
const f = controllerFixture(); const [first, target] = f.blocks;
const input = inputMethods(f);
first.node.setPosition(30, 40);
const press = { getLocation: () => v2(30, 40) };
assert.equal(input.touchStart.call(first, press), true);
assert.equal(f.controller.casting, true); assert.equal(f.controller.selection.block, target);
assert.equal(f.map.pause, undefined); assert.equal(f.controller.energy.value, 0);
f.controller.capture(first, { getLocation: () => v2(100, 200) }, false);
f.controller.update(1.49); assert.equal(f.applied.length, 0);
assert.equal(f.controller.casting, true); assert.equal(f.controller.shield.active, true);
assert.equal(f.controller.isInputLocked(first), false); assert.equal(f.controller.isInputLocked(target), true);
assert.equal(input.touchStart.call(target, press), false);
assert.notEqual(target.isTouch, true);
target.isTouch = true; target.touchDelta = v2(20, 20);
input.touchMove.call(target, { getLocation() { throw Error('locked target read a touch'); } });
input.update.call(target, 1 / 60);
assert.deepEqual(plain(target.node.position), { x: 0, y: 0 });
target.isTouch = false; target.touchDelta = v2();
const move = { getLocation: () => v2(90, 120), getDelta: () => v2() };
input.touchMove.call(first, move);
assert.deepEqual(plain(first.touchDelta), { x: 60, y: 80 });
input.update.call(first, 1 / 60);
assert.deepEqual(plain(first.node.position), { x: 90, y: 120 });
input.touchEnd.call(first, move);
assert.equal(first.isTouch, false); assert.equal(f.map.touchIng, false);
assert.equal(f.map.total_steps_count, 1); assert.equal(f.controller.casting, true);
assert.equal(input.touchStart.call(first, move), true, 'another gesture may start while the spell flies');
f.controller.update(rules.RAINBOW_CAST_SECONDS - .01); assert.equal(f.applied.length, 0);
assert.equal(f.controller.isInputLocked(target), true);
f.controller.update(.01); assert.equal(f.applied.length, 1); assert.equal(f.controller.casting, false);
assert.equal(f.controller.shield, null);
assert.deepEqual(plain(first.relative_Position), { x: -70, y: -160 });
assert.equal(first.touchDelta.mag(), 0); assert.equal(first.isTouch, true);
assert.equal(f.controller.isInputLocked(target), false);
input.touchEnd.call(first, move);
assert.equal(input.touchStart.call(target, press), true);
});
test('flight also locks team, adhesive and stacked members that could move its target', () => {
for (const relation of ['team', 'adhesive', 'stack']) {
const first = block(), target = block(1, relation === 'adhesive' ? 9 : relation === 'stack' ? 1 : 0);
const member = block(0, relation === 'stack' ? 10 : 0);
if (relation === 'team') target.teamBlocks = member.teamBlocks = [target.node, member.node];
else { target.block_Info.node = member.node; member.block_Info.node = target.node; }
const f = controllerFixture([first, target, member]);
const input = inputMethods(f);
f.controller.onPress(first);
assert.equal(f.controller.selection.block, target, relation);
assert.equal(f.controller.isInputLocked(first), false, relation);
for (const b of [target, member]) {
assert.equal(f.controller.isInputLocked(b), true, relation);
assert.equal(input.touchStart.call(b, { getLocation: () => v2() }), false, relation);
}
f.controller.update(rules.RAINBOW_CAST_SECONDS);
for (const b of [target, member]) assert.equal(f.controller.isInputLocked(b), false, relation);
}
});
test('one-way floor team links lock the driver and exclude either side of the first gesture', () => {
const target = block(), driver = block(0);
driver.teamBlocks = [target.node];
const f = controllerFixture([block(0), target, driver]);
f.controller.onPress(f.blocks[0]);
assert.equal(f.controller.selection.block, target);
assert.equal(f.controller.isInputLocked(target), true);
assert.equal(f.controller.isInputLocked(driver), true, 'remaining one-way link can still move the target');
for (const pressed of [0, 1]) {
const detached = block(), teammate = block(), other = block();
teammate.teamBlocks = [detached.node];
const g = controllerFixture([detached, teammate, other]);
g.controller.onPress(g.blocks[pressed]);
assert.equal(g.controller.selection.block, other, 'exclude both linked sides of the first gesture');
assert.equal(g.controller.isInputLocked(detached), false);
assert.equal(g.controller.isInputLocked(teammate), false);
}
});
test('rainbow trail pauses without restarting, fades after arrival even when bottle stops, and releases textures', () => {
@ -224,14 +332,15 @@ test('rainbow trail pauses without restarting, fades after arrival even when bot
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 elapsed = rules.RAINBOW_CAST_SECONDS * .4;
f.controller.update(elapsed);
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.equal(f.applied.length, 0); assert.equal(f.controller.isInputLocked(f.blocks[1]), 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);
f.map.pause = false; f.controller.update(rules.RAINBOW_CAST_SECONDS - elapsed);
assert.equal(f.applied.length, 1); assert.equal(f.controller.isInputLocked(f.blocks[1]), false);
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);
@ -247,29 +356,57 @@ test('rainbow trail pauses without restarting, fades after arrival even when bot
assert.equal(f.controller.fadingFlights.length, 0);
});
test('scene exit disposes an in-flight rainbow effect and input shield immediately', () => {
test('scene exit disposes an in-flight rainbow effect and releases the target lock 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 target = f.controller.selection.block;
assert.equal(f.controller.isInputLocked(target), true);
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(f.applied.length, 0); assert.equal(f.controller.isInputLocked(target), false);
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', () => {
test('a target eliminated during flight consumes the cast and releases its lock; 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;
first.touchEnd = () => { ends++; first.isTouch = false; };
f.controller.onPress(first, { getLocation: () => v2() });
f.controller.capture(first, { getLocation: () => v2(4, 5) }, true);
f.controller.onPress(first);
const target = f.controller.selection.block;
assert.equal(f.controller.isInputLocked(target), true);
f.map.blocks.pop(); f.controller.update(rules.RAINBOW_CAST_SECONDS); f.controller.update(rules.RAINBOW_CAST_SECONDS);
assert.equal(ends, 1); assert.equal(f.applied.length, 0); assert.equal(f.controller.energy.value, 2 * rules.RAINBOW_CAST_SECONDS);
assert.equal(f.controller.isInputLocked(target), false);
assert.equal(f.applied.length, 0); assert.equal(f.controller.energy.value, 2 * rules.RAINBOW_CAST_SECONDS);
const linked = controllerFixture(); linked.blocks[0].teamBlocks = linked.map.blocks;
linked.controller.onPress(linked.blocks[0], { getLocation: () => v2() });
assert.equal(linked.controller.energy.value, 0); assert.equal(linked.controller.casting, false);
linked.blocks.forEach(b => assert.equal(linked.controller.isInputLocked(b), false));
});
test('ending a game during flight cancels the cut and clears only the temporary input lock', () => {
for (const state of ['gameOver', 'gameWin']) {
const target = block(5, 0, { floor: true, floorMove: false });
const f = controllerFixture([block(), target]);
f.controller.onPress(f.blocks[0]);
assert.equal(f.controller.isInputLocked(target), true);
f.map[state] = true; f.controller.update(.1);
assert.equal(f.controller.isInputLocked(target), false); assert.equal(f.controller.casting, false);
assert.equal(f.applied.length, 0); assert.equal(target.block_Info.floorMove, false);
}
});
test('natural floor and freeze state changes during flight are not rolled back when its lock clears', () => {
const target = block(5, 4, { floor: true, floorMove: false });
const f = controllerFixture([block(), target]);
f.controller.onPress(f.blocks[0]);
assert.equal(target.type, 4); assert.equal(target.block_Info.floorMove, false);
// Another elimination resolves the original restrictions while the spell is in flight.
target.type = 0; target.block_Info.type = 0; target.block_Info.floorMove = true;
f.controller.update(rules.RAINBOW_CAST_SECONDS);
assert.equal(f.controller.isInputLocked(target), false);
assert.equal(target.type, 0); assert.equal(target.block_Info.floorMove, true);
const input = inputMethods(f);
assert.equal(input.touchStart.call(target, { getLocation: () => v2() }), true);
});
test('full bottle waits for release and every countdown pause; freeze elimination adds energy once', () => {
@ -436,6 +573,35 @@ test('cut retains floor and bomb component state and removes only the cut cell f
assert.equal(f.effects(), 0);
});
test('finishing a cut keeps an immovable floor remainder immovable while its new rainbow accepts dragging', () => {
const f = cutFixture(5);
Object.assign(f.b.block_Info, { floor: true, floorMove: false, floorTime: 7 });
let rainbow;
const c = controllerFixture([block(0), f.b], (_map, target, choice) => {
rainbow = f.cut.applyRainbowCut(f.map, target, choice).getComponent('Block');
});
const input = inputMethods(c);
const press = { getLocation: () => v2() };
c.controller.onPress(c.blocks[0]);
assert.equal(c.controller.isInputLocked(f.b), true);
assert.equal(input.touchStart.call(f.b, press), false);
c.controller.update(rules.RAINBOW_CAST_SECONDS);
assert.equal(c.controller.isInputLocked(f.b), false);
assert.equal(f.b.block_Info.floorMove, false); assert.equal(f.b.block_Info.floorTime, 7);
assert.equal(f.b.block_Info.floor, true);
const after = inputMethods(c, [f.b, rainbow]);
assert.equal(after.touchStart.call(f.b, press), undefined, 'original floor movement rule still rejects touch');
assert.notEqual(f.b.isTouch, true);
assert.equal(rainbow.type, 0); assert.equal(rainbow.color, 100);
assert.equal(rainbow.block_Info.floor, undefined); assert.equal(rainbow.block_Info.floorMove, undefined);
assert.equal(c.controller.isInputLocked(rainbow), false);
assert.equal(after.touchStart.call(rainbow, press), true);
const before = rainbow.node.position;
after.touchMove.call(rainbow, { getLocation: () => v2(10, 10), getDelta: () => v2() });
after.update.call(rainbow, 1 / 60);
assert.deepEqual(plain(rainbow.node.position), { x: before.x + 10, y: before.y + 10 });
});
test('rainbow elimination advances numeric counters and vines, never switches or count bombs', () => {
const f = cutFixture(1); const removed = block(0).node;
const amounts = {};