diff --git a/assets/Scene/GameScene.fire b/assets/Scene/GameScene.fire index cf544bb..8b0f3a4 100644 --- a/assets/Scene/GameScene.fire +++ b/assets/Scene/GameScene.fire @@ -3715,7 +3715,7 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -13.925, + 6.12, 129.696, 0, 0, diff --git a/assets/Script/Block.ts b/assets/Script/Block.ts index c6aa4aa..2e2e777 100644 --- a/assets/Script/Block.ts +++ b/assets/Script/Block.ts @@ -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; diff --git a/assets/Script/Map.ts b/assets/Script/Map.ts index b56f3a0..f91ad7d 100644 --- a/assets/Script/Map.ts +++ b/assets/Script/Map.ts @@ -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; } diff --git a/assets/Script/RainbowBottleWater.ts b/assets/Script/RainbowBottleWater.ts new file mode 100644 index 0000000..f79eae3 --- /dev/null +++ b/assets/Script/RainbowBottleWater.ts @@ -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]); + } +} diff --git a/assets/Script/RainbowBottleWater.ts.meta b/assets/Script/RainbowBottleWater.ts.meta new file mode 100644 index 0000000..3999f08 --- /dev/null +++ b/assets/Script/RainbowBottleWater.ts.meta @@ -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": {} +} \ No newline at end of file diff --git a/assets/Script/RainbowStreak.ts b/assets/Script/RainbowStreak.ts index c189816..0196d4c 100644 --- a/assets/Script/RainbowStreak.ts +++ b/assets/Script/RainbowStreak.ts @@ -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(); private bottle: cc.Node; - private shield: cc.Node = null; + private lockedNodes = new Set(); 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([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 }); - } } } diff --git a/assets/resources/shader/rainbow_bottle.effect b/assets/resources/shader/rainbow_bottle.effect new file mode 100644 index 0000000..8d2724d --- /dev/null +++ b/assets/resources/shader/rainbow_bottle.effect @@ -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 + #include + 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 + 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 + } +}% diff --git a/assets/resources/shader/rainbow_bottle.effect.meta b/assets/resources/shader/rainbow_bottle.effect.meta new file mode 100644 index 0000000..5944a01 --- /dev/null +++ b/assets/resources/shader/rainbow_bottle.effect.meta @@ -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": {} +} \ No newline at end of file diff --git a/assets/resources/shader/rainbow_bottle.mtl b/assets/resources/shader/rainbow_bottle.mtl new file mode 100644 index 0000000..fdf86c3 --- /dev/null +++ b/assets/resources/shader/rainbow_bottle.mtl @@ -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": {} + } + } +} diff --git a/assets/resources/shader/rainbow_bottle.mtl.meta b/assets/resources/shader/rainbow_bottle.mtl.meta new file mode 100644 index 0000000..c0bc685 --- /dev/null +++ b/assets/resources/shader/rainbow_bottle.mtl.meta @@ -0,0 +1,7 @@ +{ + "ver": "1.0.5", + "uuid": "a8764ad0-ec22-4e0f-8a24-267e49efcd8d", + "importer": "material", + "dataAsSubAsset": null, + "subMetas": {} +} \ No newline at end of file diff --git a/settings/project.json b/settings/project.json index 1778c9f..cf0610a 100644 --- a/settings/project.json +++ b/settings/project.json @@ -1,5 +1,5 @@ { - "last-module-event-record-time": 1788866731401, + "last-module-event-record-time": 1789474233869, "group-list": [ "default", "Map" diff --git a/tools/test-rainbow-streak.cjs b/tools/test-rainbow-streak.cjs index dd26a31..35c054c 100644 --- a/tools/test-rainbow-streak.cjs +++ b/tools/test-rainbow-streak.cjs @@ -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 = {};