Files
MatchMaster/assets/Script/StartBoostOpening.ts
T
2026-10-16 16:24:28 +08:00

461 lines
24 KiB
TypeScript

import { planRocketShots, RocketShot } from "./RocketRules";
import { applyRocketCut } from "./RocketCut";
import NumberToImage from "./NumberToImage";
import { StartBoostLoadout, StartBoostProp, startBoostRewards } from "./StartBoostLoadout";
const ENTRY_SECONDS = 0.65;
const ENTRY_HOLD_SECONDS = 0.3;
const LAUNCH_CLOUD_SECONDS = 0.65;
const HOURGLASS_SPEED = 1320;
const ROCKET_SPEED = 950;
const ROCKET_ARC_HEIGHT = 480;
// 复用炸弹的爆炸段,跳过火箭命中后不需要的引爆前摇。
const ROCKET_IMPACT_START_SECONDS = 0.3333;
const HOURGLASS_FLIGHT_SCALE = 0.7;
function flightPoint(from: cc.Vec2, control: cc.Vec2, to: cc.Vec2, t: number): cc.Vec2 {
const u = 1 - t;
return cc.v2(u * u * from.x + 2 * u * t * control.x + t * t * to.x,
u * u * from.y + 2 * u * t * control.y + t * t * to.y);
}
function flightDistances(from: cc.Vec2, control: cc.Vec2, to: cc.Vec2): number[] {
const distances = [0];
let previous = from;
for (let sample = 1; sample <= 64; sample++) {
const point = flightPoint(from, control, to, sample / 64);
distances.push(distances[sample - 1] + Math.hypot(point.x - previous.x, point.y - previous.y));
previous = point;
}
return distances;
}
interface OpeningFlight {
node: cc.Node;
from: cc.Vec2;
to: cc.Vec2;
control: cc.Vec2;
distances: number[];
shot: RocketShot;
trail: cc.ParticleSystem;
timeText: cc.Node;
landed: boolean;
impact: cc.Node;
impactElapsed: number;
impactSeconds: number;
}
interface CarriedEntry {
node: cc.Node;
rocket: boolean;
from: cc.Vec2;
to: cc.Vec2;
trail: cc.ParticleSystem;
arrived: boolean;
}
/** 按本局连胜奖励与自选道具播放开场,携带道具实际发射时通知扣除。 */
export default class StartBoostOpening {
private root = new cc.Node("startBoostOpening");
private box: cc.Node;
private items: cc.Node[] = [];
private flights: OpeningFlight[] = [];
private carriedEntries: CarriedEntry[] = [];
private hourglassCount: number;
private hourglassHeight: number;
private rocketCount: number;
private boxScale: cc.Vec2;
private elapsed = 0;
private launched = false;
private smokeTexture: cc.Texture2D;
private smokeFrame: cc.SpriteFrame;
private cloudTexture: cc.Texture2D;
private cloudFrame: cc.SpriteFrame;
private sparkleTexture: cc.Texture2D;
private sparkleFrame: cc.SpriteFrame;
private launchCloud: cc.Node;
private displayedTime: number = null;
private timeTarget: number = null;
private hidden = false;
private disposed = false;
private hide = () => { this.hidden = true; this.root.active = false; };
private show = () => { this.hidden = false; };
constructor(private map: any, loadout: StartBoostLoadout, private onUseCarried: (prop: StartBoostProp) => void) {
if (!map.hourglassPrefab || !map.rocketPrefab || !map.rocketBoxPrefab) throw new Error("缺少沙漏、火箭或 rocketBox 预制体");
const canvas = cc.find("Canvas");
canvas.addChild(this.root);
this.root.zIndex = cc.macro.MAX_ZINDEX - 1;
this.root.setPosition(canvas.convertToNodeSpaceAR(cc.v2(cc.winSize.width / 2, cc.winSize.height / 2)));
this.box = cc.instantiate(map.rocketBoxPrefab);
this.box.setPosition(0, 160);
const free = startBoostRewards(loadout.streak);
this.box.active = free.hourglass + free.rocket > 0;
if (this.box.active) {
const sprite = this.box.getComponent(cc.Sprite);
const frame = map.SceneManager.getStartBoostBoxFrame(loadout.streak);
if (frame && frame !== sprite.spriteFrame) {
const height = sprite.sizeMode === cc.Sprite.SizeMode.CUSTOM ? this.box.height : sprite.spriteFrame.getRect().height;
const size = frame.getRect();
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM;
sprite.spriteFrame = frame;
this.box.setContentSize(height * size.width / size.height, height);
}
}
this.boxScale = cc.v2(this.box.scaleX, this.box.scaleY);
this.hourglassCount = free.hourglass + (loadout.hourglass ? 1 : 0);
this.rocketCount = free.rocket + (loadout.rocket ? 1 : 0);
if (!this.hourglassCount) {
const reference = cc.instantiate(map.hourglassPrefab);
this.root.addChild(reference);
this.hourglassHeight = reference.height;
reference.active = false;
reference.destroy();
}
for (let i = 0; i < this.hourglassCount + this.rocketCount; i++) {
const rocket = i >= this.hourglassCount;
const node = cc.instantiate(rocket ? map.rocketPrefab : map.hourglassPrefab);
node.parent = this.root;
if (!rocket) this.hourglassHeight = node.height;
node.setScale(rocket ? this.hourglassHeight * 0.65 / node.height : 0.65);
node.setPosition(rocket ? -42 + (i - this.hourglassCount) * 84 : -54 + i * 54, this.box.y + 24);
// 箱子贴图已经绘有道具;实体在飞出时显示,避免重复叠放图案。
node.active = false;
this.items.push(node);
if ((!rocket && i === free.hourglass) || (rocket && i === this.hourglassCount + free.rocket)) {
const from = this.root.convertToNodeSpaceAR(cc.v2(rocket ? cc.winSize.width - 60 : 60, -80));
this.carriedEntries.push({ node, rocket, from, to: cc.v2(rocket ? 280 : -280, -500),
trail: null, arrived: false });
node.setPosition(from);
}
}
this.root.addChild(this.box);
this.box.opacity = 0;
this.root.active = false;
cc.game.on(cc.game.EVENT_HIDE, this.hide);
cc.game.on(cc.game.EVENT_SHOW, this.show);
}
update(dt: number, paused: boolean): boolean {
if (this.disposed) return true;
this.root.active = !this.hidden && !paused;
if (!this.root.active) return false;
this.elapsed += dt;
if (!this.launched) {
this.updateEntry();
// 三个入场动画同时结束,原地停顿 0.3 秒并出现烟团,再一起飞出。
if (this.elapsed >= ENTRY_SECONDS) this.updateLaunchCloud(
Math.min(1, (this.elapsed - ENTRY_SECONDS) / ENTRY_HOLD_SECONDS), false);
if (this.elapsed >= ENTRY_SECONDS + ENTRY_HOLD_SECONDS) this.beginFlights();
return false;
}
this.updateLaunchCloud(Math.min(1, this.elapsed / LAUNCH_CLOUD_SECONDS), true);
this.updateTimeDisplay(dt);
for (const flight of this.flights) {
if (flight.landed) {
flight.impactElapsed += dt;
continue;
}
const rocket = !!flight.shot;
const distances = flight.distances;
const distance = Math.min(distances[distances.length - 1], this.elapsed * (rocket ? ROCKET_SPEED : HOURGLASS_SPEED));
const end = distances.findIndex((value, index) => index > 0 && value >= distance);
const fraction = (distance - distances[end - 1]) / (distances[end] - distances[end - 1]);
const t = (end - 1 + fraction) / (distances.length - 1);
const u = 1 - t;
const control = flight.control;
const node = flight.node;
node.setPosition(flightPoint(flight.from, control, flight.to, t));
if (rocket) {
// 沙漏本轮只缩小飞行显示,火箭沿用原有的尺寸变化。
const referenceScale = this.hourglassCount ? this.items[0].scaleY / HOURGLASS_FLIGHT_SCALE : 0.65 - 0.25 * t;
node.setScale(this.hourglassHeight * referenceScale / node.height);
const dx = u * (control.x - flight.from.x) + t * (flight.to.x - control.x);
const dy = u * (control.y - flight.from.y) + t * (flight.to.y - control.y);
node.angle = Math.atan2(dy, dx) * 180 / Math.PI - 90;
} else {
node.setScale((0.65 - 0.25 * t) * HOURGLASS_FLIGHT_SCALE);
flight.timeText.setPosition(node.x - node.width * node.scaleX / 4,
node.y + node.height * node.scaleY / 2 - 8);
}
flight.trail.node.setPosition(this.root.convertToNodeSpaceAR(
node.convertToWorldSpaceAR(cc.v2(0, rocket ? -node.height : 0))));
if (t === 1) this.land(flight);
}
return this.elapsed >= LAUNCH_CLOUD_SECONDS && this.displayedTime === this.timeTarget &&
this.carriedEntries.every(entry => this.elapsed + ENTRY_HOLD_SECONDS >= entry.trail.life + entry.trail.lifeVar) &&
this.flights.every(flight => flight.landed && flight.impactElapsed >= flight.impactSeconds);
}
private updateEntry(): void {
const progress = Math.min(1, this.elapsed / ENTRY_SECONDS);
const back = progress - 1;
const scale = 0.6 + 0.4 * (1 + 2.70158 * back * back * back + 1.70158 * back * back);
this.box.setScale(this.boxScale.x * scale, this.boxScale.y * scale);
this.box.opacity = Math.round(255 * Math.min(1, progress * 4));
const v = progress * progress * (3 - 2 * progress), u = 1 - v;
for (const entry of this.carriedEntries) {
if (entry.arrived) continue;
if (!entry.trail) {
entry.node.active = true;
entry.trail = this.makeTrail(entry.rocket);
entry.node.setSiblingIndex(this.root.childrenCount - 1);
}
const control = cc.v2(entry.to.x, entry.from.y + (entry.to.y - entry.from.y) * 0.55);
entry.node.setPosition(u * u * entry.from.x + 2 * u * v * control.x + v * v * entry.to.x,
u * u * entry.from.y + 2 * u * v * control.y + v * v * entry.to.y);
if (entry.rocket) {
const dx = u * (control.x - entry.from.x) + v * (entry.to.x - control.x);
const dy = u * (control.y - entry.from.y) + v * (entry.to.y - control.y);
entry.node.angle = Math.atan2(dy, dx) * 180 / Math.PI - 90;
}
entry.trail.node.setPosition(this.root.convertToNodeSpaceAR(entry.node.convertToWorldSpaceAR(
cc.v2(0, entry.rocket ? -entry.node.height * entry.node.anchorY : 0))));
if (progress === 1) {
entry.arrived = true;
entry.node.angle = 0;
entry.trail.stopSystem();
}
}
}
private beginFlights(): void {
const shots = planRocketShots(this.map, this.rocketCount);
const rocketDistances = new Map<string, number>();
this.elapsed = 0;
this.launched = true;
this.box.active = false;
for (let i = this.hourglassCount + shots.length; i < this.items.length; i++) this.items[i].active = false;
for (let i = 0; i < this.hourglassCount + shots.length; i++) {
const node = this.items[i], shot = i >= this.hourglassCount ? shots[i - this.hourglassCount] : null;
const destination = shot ? this.map.mapBlocksWall[shot.x][shot.y]
: this.map.timeLabel.parent.getChildByName("time_icon");
const to = this.root.convertToNodeSpaceAR(destination.convertToWorldSpaceAR(cc.v2(0, 0)));
node.active = true;
if (shot) {
// 用火箭尖端作飞行锚点,爆炸落点精确落在被裁掉的格子上。
const tip = node.convertToWorldSpaceAR(cc.v2(0, node.height * (1 - node.anchorY)));
node.anchorY = 1;
node.setPosition(this.root.convertToNodeSpaceAR(tip));
} else {
node.setScale(node.scaleX * HOURGLASS_FLIGHT_SCALE);
}
const trail = this.makeTrail(!!shot);
trail.node.setPosition(this.root.convertToNodeSpaceAR(
node.convertToWorldSpaceAR(cc.v2(0, shot ? -node.height : 0))));
node.setSiblingIndex(this.root.childrenCount - 1);
let timeText: cc.Node = null;
if (!shot) {
timeText = new cc.Node("hourglassPlusTen");
this.root.addChild(timeText);
timeText.anchorX = 1;
const label = timeText.addComponent(cc.Label);
label.string = "+10";
label.fontSize = 54;
label.lineHeight = 60;
label.enableBold = true;
const outline = timeText.addComponent(cc.LabelOutline);
outline.color = cc.color(55, 45, 35);
outline.width = 3;
timeText.setPosition(node.x - node.width * node.scaleX / 4,
node.y + node.height * node.scaleY / 2 - 8);
}
const from = cc.v2(node.x, node.y);
const control = shot ? cc.v2((from.x + to.x) / 2, Math.max(from.y, to.y) + ROCKET_ARC_HEIGHT)
// 沙漏交替绕左、绕右,后续沙漏使用更宽、不同高度的弧线,最后汇入时钟。
: cc.v2((from.x + to.x) / 2 + (i % 2 === 0 ? -1 : 1) * (240 + Math.floor(i / 2) * 180),
from.y + (to.y - from.y) * (0.4 + i * 0.12));
let distances = flightDistances(from, control, to);
if (shot) {
const previous = rocketDistances.get(shot.origin);
// 同一原块后续命中至少晚 0.1 秒;延长弧线,保持同时起飞和同速。
const minimum = previous === undefined ? 0 : previous + ROCKET_SPEED * 0.1;
if (distances[distances.length - 1] < minimum) {
let low = control.y, high = control.y + minimum;
for (let step = 0; step < 12; step++) {
control.y = (low + high) / 2;
const trial = flightDistances(from, control, to);
if (trial[trial.length - 1] < minimum) low = control.y;
else high = control.y;
}
control.y = high;
distances = flightDistances(from, control, to);
}
rocketDistances.set(shot.origin, distances[distances.length - 1]);
}
this.flights.push({ node, shot, to, from, control, distances, trail, timeText,
landed: false, impact: null, impactElapsed: 0, impactSeconds: trail.life + trail.lifeVar });
if (this.carriedEntries.some(entry => entry.node === node)) this.onUseCarried(shot ? "rocket" : "hourglass");
}
}
private land(flight: OpeningFlight): void {
flight.node.active = false;
flight.landed = true;
flight.trail.stopSystem();
if (!flight.shot) {
flight.timeText.active = false;
if (this.displayedTime === null) this.displayedTime = this.map.timeNumber;
this.map.addTime(10, false);
this.timeTarget = this.map.timeNumber;
return;
} else {
const bomb = cc.instantiate(this.map.Block_Prop[6]);
flight.impact = cc.instantiate(bomb.getChildByName("zhandan"));
bomb.destroy();
flight.impact.active = true;
const skeleton = flight.impact.getComponent(sp.Skeleton);
this.root.addChild(flight.impact);
flight.impact.setPosition(flight.to);
const track = skeleton.setAnimation(0, "eff", false);
track.trackTime = Math.min(ROCKET_IMPACT_START_SECONDS, track.animation.duration);
flight.impactSeconds = Math.max(flight.impactSeconds, track.animation.duration - track.trackTime);
const shot = flight.shot;
const id = this.map.mapBlocksWall[shot.x][shot.y].getComponent("MapBlock").block_Id;
const block = this.map.blocks.find(node => node.uuid === id).getComponent("Block");
applyRocketCut(this.map, block, shot.cut);
}
if (!flight.impact.parent) this.root.addChild(flight.impact);
flight.impact.setPosition(flight.to);
}
private updateTimeDisplay(dt: number): void {
if (this.displayedTime === null || this.displayedTime === this.timeTarget) return;
const previous = Math.floor(this.displayedTime);
this.displayedTime = Math.min(this.timeTarget, this.displayedTime + dt * 30);
const current = Math.floor(this.displayedTime);
if (current !== previous) NumberToImage.getTimeMargi3(current, 45, "company_", this.map.timeLabel);
}
private updateLaunchCloud(progress: number, dispersing: boolean): void {
if (!this.launchCloud) {
const size = 64, pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const radius = Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) / (size / 2);
const softness = Math.max(0, 1 - radius * radius);
const offset = (y * size + x) * 4;
pixels[offset] = pixels[offset + 1] = pixels[offset + 2] = 255;
pixels[offset + 3] = Math.round(160 * softness * softness);
}
this.cloudTexture = new cc.Texture2D();
this.cloudTexture.initWithData(pixels, cc.Texture2D.PixelFormat.RGBA8888, size, size);
this.cloudFrame = new cc.SpriteFrame(this.cloudTexture);
this.launchCloud = new cc.Node("launchCloud");
this.root.addChild(this.launchCloud);
// 箱子和实际携带的道具各有独立烟团,中心固定,散开时也留出间隔。
const targets = [...(this.box.active ? [this.box] : []), ...this.carriedEntries.map(entry => entry.node)];
targets.forEach(target => {
const cloud = new cc.Node("cloud");
this.launchCloud.addChild(cloud);
cloud.setPosition(this.root.convertToNodeSpaceAR(target.convertToWorldSpaceAR(
cc.v2(target.width * (0.5 - target.anchorX), target.height * (0.5 - target.anchorY)))));
// 统一圆形轮廓,只按包围圆调整大小,不跟随物品的长宽比例拉伸。
cloud.setScale((Math.hypot(target.width * target.scaleX, target.height * target.scaleY) + 20) / 300
* (target === this.box ? 0.7 : 1));
for (let i = 0; i < 19; i++) {
const puff = new cc.Node("puff");
cloud.addChild(puff);
puff.addComponent(cc.Sprite).spriteFrame = this.cloudFrame;
}
});
}
this.launchCloud.active = !(dispersing && progress === 1);
if (!this.launchCloud.active) return;
this.launchCloud.opacity = Math.round(255 * (dispersing ? 1 : Math.min(1, progress * 2)));
this.launchCloud.children.forEach(cloud => cloud.children.forEach((puff, i) => {
const layer = i === 0 ? 0 : i <= 6 ? 1 : 2;
const radius = layer * 55;
const angle = i <= 6 ? (i - 1) * Math.PI / 3 : (i - 7) * Math.PI / 6;
// 外层先散,内层稍后散;同层也略微错开,避免整团一起消失。
const delay = (2 - layer) * 0.1 + i % 3 * 0.025;
const phase = dispersing ? Math.min(1, Math.max(0, (progress - delay) / (0.8 - layer * 0.1))) : 0;
const fade = phase * phase * (3 - 2 * phase);
puff.opacity = Math.round((155 - layer * 20 - i % 3 * 9) * (1 - fade));
const spread = dispersing ? 1 + fade * (0.07 + layer * 0.04) : 0.6 + progress * 0.4;
puff.setPosition(Math.cos(angle) * radius * spread, Math.sin(angle) * radius * spread);
puff.setScale((2.5 + i % 3 * 0.05) * (dispersing ? 1 + fade * 0.1 : 0.5 + progress * 0.5));
}));
}
private ensureSmokeFrame(): void {
if (!this.smokeFrame) {
const size = 32, pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const radius = Math.hypot(x + 0.5 - size / 2, y + 0.5 - size / 2) / (size / 2);
const offset = (y * size + x) * 4;
pixels[offset] = pixels[offset + 1] = pixels[offset + 2] = 255;
pixels[offset + 3] = Math.round(255 * Math.pow(Math.max(0, 1 - radius), 2));
}
this.smokeTexture = new cc.Texture2D();
this.smokeTexture.initWithData(pixels, cc.Texture2D.PixelFormat.RGBA8888, size, size);
this.smokeFrame = new cc.SpriteFrame(this.smokeTexture);
}
}
private makeTrail(rocket: boolean): cc.ParticleSystem {
if (rocket) this.ensureSmokeFrame();
else this.ensureSparkleFrame();
const node = new cc.Node(rocket ? "rocketSmoke" : "hourglassGlow");
this.root.addChild(node);
const smoke = node.addComponent(cc.ParticleSystem);
smoke.custom = true;
smoke.playOnLoad = false;
smoke.spriteFrame = rocket ? this.smokeFrame : this.sparkleFrame;
smoke.duration = -1;
smoke.positionType = cc.ParticleSystem.PositionType.FREE;
smoke.totalParticles = rocket ? 180 : 40;
smoke.emissionRate = rocket ? 180 : 35;
smoke.life = rocket ? 0.65 : 0.5;
smoke.lifeVar = 0.1;
smoke.startSize = rocket ? 48 : 32;
smoke.startSizeVar = rocket ? 10 : 12;
smoke.endSize = rocket ? 100 : 10;
smoke.endSizeVar = rocket ? 16 : 4;
smoke.speed = rocket ? 12 : 8;
smoke.speedVar = rocket ? 5 : 4;
smoke.angleVar = 180;
smoke.gravity = cc.v2(0, rocket ? -12 : 0);
smoke.startColor = rocket ? cc.color(190, 190, 190, 235) : cc.color(255, 255, 255, 255);
smoke.endColor = rocket ? cc.color(130, 130, 130, 0) : cc.color(255, 255, 255, 0);
smoke.srcBlendFactor = cc.macro.SRC_ALPHA;
smoke.dstBlendFactor = rocket ? cc.macro.ONE_MINUS_SRC_ALPHA : cc.macro.ONE;
smoke.resetSystem();
return smoke;
}
private ensureSparkleFrame(): void {
if (this.sparkleFrame) return;
// 白色亮点、柔光和短星芒;与烟雾分开,避免影响火箭及开箱烟团。
const size = 64, pixels = new Uint8Array(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const dx = Math.abs((x + 0.5 - size / 2) / (size / 2));
const dy = Math.abs((y + 0.5 - size / 2) / (size / 2));
const radiusSquared = dx * dx + dy * dy;
const glow = Math.exp(-radiusSquared * 24) + 0.18 * Math.exp(-radiusSquared * 5);
const rays = 0.7 * (Math.exp(-dx * 45) * Math.pow(1 - dy, 2)
+ Math.exp(-dy * 45) * Math.pow(1 - dx, 2));
const offset = (y * size + x) * 4;
pixels[offset] = pixels[offset + 1] = pixels[offset + 2] = 255;
pixels[offset + 3] = Math.round(255 * Math.min(1, Math.max(glow, rays)));
}
this.sparkleTexture = new cc.Texture2D();
this.sparkleTexture.initWithData(pixels, cc.Texture2D.PixelFormat.RGBA8888, size, size);
this.sparkleFrame = new cc.SpriteFrame(this.sparkleTexture);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
cc.game.off(cc.game.EVENT_HIDE, this.hide);
cc.game.off(cc.game.EVENT_SHOW, this.show);
this.root.active = false;
this.root.destroy();
if (this.smokeFrame) this.smokeFrame.destroy();
if (this.smokeTexture) this.smokeTexture.destroy();
if (this.cloudFrame) this.cloudFrame.destroy();
if (this.cloudTexture) this.cloudTexture.destroy();
if (this.sparkleFrame) this.sparkleFrame.destroy();
if (this.sparkleTexture) this.sparkleTexture.destroy();
}
}