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(); 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(); } }