WeaponBullet.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { _decorator, Component, Node, Vec2, Vec3, RigidBody2D, Collider2D, Contact2DType, IPhysics2DContact, find, Prefab, instantiate, UITransform, resources, sp } from 'cc';
  2. const { ccclass, property } = _decorator;
  3. /**
  4. * WeaponBullet 负责两种子弹行为:
  5. * 1) explosive – 击中敌人立即销毁并播放 hitEffect。
  6. * 2) piercing – 击中敌人不销毁,持续飞行;若设置 trailEffect 则在尾部循环播放。
  7. *
  8. * 使用方式:
  9. * const wb = bullet.addComponent(WeaponBullet);
  10. * wb.init({
  11. * behavior : 'explosive' | 'piercing',
  12. * speed : number,
  13. * damage : number,
  14. * hitEffect: 'Animation/tx/tx000X',
  15. * trailEffect?: 'Animation/tx/tx000X'
  16. * });
  17. */
  18. export type BulletBehaviour = 'explosive' | 'piercing' | 'ricochet' | 'boomerang' | 'area_burn' | 'homing' | 'normal';
  19. export interface BulletInitData {
  20. behavior: BulletBehaviour;
  21. speed: number;
  22. damage: number;
  23. hitEffect?: string; // resources 路径
  24. trailEffect?: string; // resources 路径
  25. lifetime?: number; // piercing 子弹有效时间
  26. ricochetCount?: number; // ricochet 弹射次数
  27. ricochetAngle?: number; // 每次弹射随机角度 (deg)
  28. arcHeight?: number; // boomerang
  29. returnDelay?: number;
  30. burnDuration?: number; // area burn
  31. homingDelay?: number; // homing missile
  32. homingStrength?: number;
  33. }
  34. @ccclass('WeaponBullet')
  35. export class WeaponBullet extends Component {
  36. private behavior: BulletBehaviour = 'explosive';
  37. private speed = 300;
  38. private damage = 1;
  39. private hitEffectPath = '';
  40. private trailEffectPath = '';
  41. private lifetime = 5;
  42. private ricochetLeft = 0;
  43. private ricochetAngle = 30; // deg
  44. // boomerang
  45. private boomerangTimer = 0;
  46. private boomerangReturnDelay = 1;
  47. private initialDir: Vec3 = new Vec3();
  48. // area burn
  49. private burnDuration = 5;
  50. // homing
  51. private homingDelay = 0.3;
  52. private homingStrength = 0.8;
  53. private homingTimer = 0;
  54. private dir: Vec3 = new Vec3(1,0,0);
  55. private rb: RigidBody2D = null;
  56. private timer = 0;
  57. public init(cfg: BulletInitData){
  58. this.behavior = cfg.behavior;
  59. this.speed = cfg.speed;
  60. this.damage = cfg.damage;
  61. this.hitEffectPath = cfg.hitEffect || '';
  62. this.trailEffectPath = cfg.trailEffect || '';
  63. if(cfg.lifetime) this.lifetime = cfg.lifetime;
  64. if(cfg.ricochetCount!==undefined) this.ricochetLeft = cfg.ricochetCount;
  65. if(cfg.ricochetAngle) this.ricochetAngle = cfg.ricochetAngle;
  66. if(this.behavior==='normal') this.behavior='explosive';
  67. if(cfg.arcHeight){ /* placeholder visual only */ }
  68. if(cfg.returnDelay){ this.boomerangReturnDelay = cfg.returnDelay; }
  69. if(cfg.burnDuration){ this.burnDuration = cfg.burnDuration; }
  70. if(cfg.homingDelay){ this.homingDelay = cfg.homingDelay; this.homingTimer = cfg.homingDelay; }
  71. if(cfg.homingStrength){ this.homingStrength = cfg.homingStrength; }
  72. }
  73. onLoad(){
  74. this.rb = this.getComponent(RigidBody2D);
  75. const col = this.getComponent(Collider2D);
  76. if(col){ col.on(Contact2DType.BEGIN_CONTACT,this.onHit,this); }
  77. // 添加拖尾
  78. if(this.behavior==='piercing' && this.trailEffectPath){
  79. this.attachTrail();
  80. }
  81. }
  82. start(){
  83. if(this.rb){
  84. const v = new Vec2(this.dir.x*this.speed, this.dir.y*this.speed);
  85. this.rb.linearVelocity = v;
  86. }
  87. this.timer = this.lifetime;
  88. this.initialDir.set(this.dir);
  89. }
  90. private onHit(self:Collider2D, other:Collider2D, contact:IPhysics2DContact|null){
  91. // 仅处理敌人
  92. const node = other.node;
  93. const isEnemy = node.getComponent('EnemyInstance')||node.getComponent('EnemyComponent');
  94. if(!isEnemy) return;
  95. // 伤害
  96. const ei = node.getComponent('EnemyInstance') as any;
  97. if(ei && typeof ei.takeDamage==='function') ei.takeDamage(this.damage);
  98. // 播放击中特效
  99. this.spawnEffect(this.hitEffectPath, node.worldPosition);
  100. switch(this.behavior){
  101. case 'explosive':
  102. this.node.destroy();
  103. break;
  104. case 'piercing':
  105. // 继续飞行
  106. break;
  107. case 'ricochet':
  108. if(this.ricochetLeft>0){
  109. this.ricochetLeft--;
  110. this.bounceDirection();
  111. }else{
  112. this.node.destroy();
  113. }
  114. break;
  115. case 'boomerang':
  116. // 命中后照样返程,由 boomerangTimer 处理,不销毁
  117. break;
  118. case 'area_burn':
  119. // 在地面生成灼烧特效
  120. this.spawnEffect(this.trailEffectPath, node.worldPosition, true);
  121. this.scheduleOnce(()=>{/* burn area ends */}, this.burnDuration);
  122. this.node.destroy();
  123. break;
  124. case 'homing':
  125. this.node.destroy();
  126. break;
  127. }
  128. }
  129. update(dt:number){
  130. if(this.behavior==='piercing'){
  131. this.timer -= dt;
  132. if(this.timer<=0){ this.node.destroy(); return; }
  133. }
  134. // 超界销毁
  135. const canvas = find('Canvas');
  136. if(!canvas) return;
  137. const halfW = canvas.getComponent(UITransform).width/2;
  138. const halfH = canvas.getComponent(UITransform).height/2;
  139. const pos = this.node.worldPosition;
  140. if(Math.abs(pos.x) > halfW+200 || Math.abs(pos.y) > halfH+200){
  141. this.node.destroy();
  142. }
  143. // boomerang返回
  144. if(this.behavior==='boomerang'){
  145. this.boomerangTimer += dt;
  146. if(this.boomerangTimer>=this.boomerangReturnDelay){
  147. this.dir.multiplyScalar(-1);
  148. if(this.rb){
  149. this.rb.linearVelocity = new Vec2(this.dir.x*this.speed, this.dir.y*this.speed);
  150. }
  151. this.behavior = 'piercing'; // 之后按穿透逻辑处理
  152. }
  153. }
  154. // homing 导弹
  155. if(this.behavior==='homing'){
  156. if(this.homingTimer>0){ this.homingTimer-=dt; }
  157. else{
  158. const enemyContainer = find('Canvas/GameLevelUI/enemyContainer');
  159. if(enemyContainer && enemyContainer.children.length>0){
  160. let nearest:Node=null; let dist=1e9;
  161. for(const e of enemyContainer.children){
  162. if(!e.active) continue;
  163. const d = Vec3.distance(this.node.worldPosition, e.worldPosition);
  164. if(d<dist){ dist=d; nearest=e; }
  165. }
  166. if(nearest){
  167. const dir = nearest.worldPosition.clone().subtract(this.node.worldPosition).normalize();
  168. // 线性插值方向
  169. this.dir.lerp(dir, this.homingStrength*dt);
  170. if(this.rb){
  171. this.rb.linearVelocity = new Vec2(this.dir.x*this.speed, this.dir.y*this.speed);
  172. }
  173. }
  174. }
  175. }
  176. }
  177. }
  178. private attachTrail(){
  179. this.spawnEffect(this.trailEffectPath, new Vec3(), true, this.node);
  180. }
  181. private spawnEffect(path:string, worldPos:Vec3, loop=false, parent?:Node){
  182. if(!path) return;
  183. const m = path.match(/tx\/(tx\d{4})/);
  184. const code = m? m[1]:'';
  185. const skPath = code?`Animation/WeaponTx/${code}/${code}`:path;
  186. resources.load(skPath, sp.SkeletonData, (err,sk:sp.SkeletonData)=>{
  187. if(err) {console.warn('load effect fail', skPath); return;}
  188. const n = new Node('Fx');
  189. const s = n.addComponent(sp.Skeleton);
  190. s.skeletonData = sk;
  191. s.setAnimation(0,'animation',loop);
  192. const targetParent = parent||find('Canvas/GameLevelUI/GameArea')||find('Canvas');
  193. if(targetParent){
  194. targetParent.addChild(n);
  195. if(parent){ n.setPosition(0,0,0);}else{
  196. const trans = targetParent.getComponent(UITransform);
  197. n.position = trans?trans.convertToNodeSpaceAR(worldPos):worldPos;
  198. }
  199. }
  200. if(!loop){ s.setCompleteListener(()=>n.destroy()); }
  201. });
  202. }
  203. private bounceDirection(){
  204. // 简单随机旋转 ricochetAngle
  205. const sign = Math.random() < 0.5 ? -1 : 1;
  206. const rad = this.ricochetAngle * (Math.PI/180) * sign;
  207. const cos = Math.cos(rad), sin = Math.sin(rad);
  208. const nx = this.dir.x * cos - this.dir.y * sin;
  209. const ny = this.dir.x * sin + this.dir.y * cos;
  210. this.dir.set(nx, ny, 0);
  211. if(this.rb){
  212. this.rb.linearVelocity = new Vec2(this.dir.x*this.speed, this.dir.y*this.speed);
  213. }
  214. }
  215. }