import Phaser from '../vendor/phaser.js'; import { toColor } from '../utils/Color.js'; import { canvasTexture } from '../utils/Textures.js'; import { assignWorld } from '../visuals/UiCameras.js'; const TEX_KEY = '__combat_bullet'; const TEX_SIZE = 32; const BOLT_KEY = '__combat_bolt'; const BOLT_W = 128; // texture px along the flight axis (the tail→head length) const BOLT_H = 32; // texture px across const BOLT_HEAD_X = 112; // where the hot head sits in texture space (the core head sits on it) /** A small, bright, soft dot — white, so a tint reads as its color. */ function drawSoftDot(ctx, size) { const c = size / 2; const g = ctx.createRadialGradient(c, c, 0, c, c, c); g.addColorStop(0.0, 'rgba(255,255,255,1.0)'); g.addColorStop(0.25, 'rgba(255,255,255,0.9)'); g.addColorStop(0.55, 'rgba(255,255,255,0.32)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); } /** * The laser bolt — white alpha, so the sprite's tint reads as its color * (the player's cyan / the lance's magenta). Laid along +x, tail at the * left, the hot head at BOLT_HEAD_X: a fading motion-trail band (the * streak reads as the bolt's own length), a bright core line, and the * head bloom. A separate untinted white core head (the soft dot) rides * the tip — the "hot-white core, colored halo" read. */ function drawBolt(ctx, w, h) { const c = h / 2; // 1) The trail band — wide, soft, fading back from the head. let g = ctx.createLinearGradient(0, 0, w, 0); g.addColorStop(0.0, 'rgba(255,255,255,0.0)'); g.addColorStop(0.45, 'rgba(255,255,255,0.18)'); g.addColorStop(0.8, 'rgba(255,255,255,0.55)'); g.addColorStop(1.0, 'rgba(255,255,255,0.85)'); ctx.fillStyle = g; ctx.fillRect(0, c - h * 0.24, w, h * 0.48); // 2) The core streak — thin and bright, most of the bolt's length. g = ctx.createLinearGradient(0, 0, w, 0); g.addColorStop(0.0, 'rgba(255,255,255,0.0)'); g.addColorStop(0.3, 'rgba(255,255,255,0.45)'); g.addColorStop(0.75, 'rgba(255,255,255,1.0)'); g.addColorStop(1.0, 'rgba(255,255,255,1.0)'); ctx.fillStyle = g; ctx.fillRect(4, c - h * 0.09, w - 8, h * 0.18); // 3) The head bloom. const hx = BOLT_HEAD_X; g = ctx.createRadialGradient(hx, c, 0, hx, c, 18); g.addColorStop(0.0, 'rgba(255,255,255,0.95)'); g.addColorStop(0.5, 'rgba(255,255,255,0.45)'); g.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = g; ctx.fillRect(hx - 18, 0, 36, h); } /** * A combat tracer — an elongated glowing BOLT that flies straight, lives * for a while, and carries the damage of the weapon that fired it. The * body is the tinted bolt (trail + halo, the weapon's color); a hot-white * core rides the head. The scene (not the bullet) decides what it hits — * the sandbox runs its collisions by hand (circle checks, like the main * game's keep-out constraints) because this build's collider API is * fiddly; a bullet just reports its radius (this.radius, world px) and * owner. * * Tuned per weapon from data/combat.json (player.weapon / enemy.weapon): * speed, damage, life, color, size (the collision dot) plus the VISUALS — * boltLength/boltWidth px (the bolt's extent) and the muzzle flash (Fx). */ export class Bullet extends Phaser.Physics.Arcade.Sprite { static TEXTURE_KEY = TEX_KEY; static BOLT_KEY = BOLT_KEY; /** Ensure the shared textures exist (canvas-generated). */ static ensureTexture(scene) { if (!scene.textures.exists(TEX_KEY)) { canvasTexture(scene, TEX_KEY, TEX_SIZE, TEX_SIZE, (ctx, w, h) => drawSoftDot(ctx, Math.min(w, h))); } if (!scene.textures.exists(BOLT_KEY)) { canvasTexture(scene, BOLT_KEY, BOLT_W, BOLT_H, (ctx, w, h) => drawBolt(ctx, w, h)); } return BOLT_KEY; } /** * @param {object} scene the combat scene * @param {object} o { x, y, angle, speed, damage, life, color, size, owner, * boltLength?, boltWidth? } * angle rad (flight direction), speed px/s, life seconds, * owner 'player' | 'enemy' (whose bullets fly whose way). */ constructor(scene, o) { Bullet.ensureTexture(scene); super(scene, o.x, o.y, BOLT_KEY); scene.add.existing(this); scene.physics.add.existing(this); assignWorld(scene, this); // world pass (the UI pass must not double-draw) this.owner = o.owner; this.damage = Math.max(0, o.damage ?? 10); this.life = Math.max(0, o.life ?? 1.2); this.dead = false; // consumed (a hit or the end of life) — consume() is one-shot // The bolt's extent (weapon.visuals, px — the texture is the unit). const size = Number(o.size) || 16; this.boltLength = Math.max(12, Number(o.boltLength) || size * 3); this.boltWidth = Math.max(4, Number(o.boltWidth) || size * 0.6); this.setScale(this.boltLength / BOLT_W, this.boltWidth / BOLT_H); this.tint = toColor(o.color, 0xffffff); this.setBlendMode(Phaser.BlendModes.ADD); // The hot-white core riding the head (untinted — the halo is tinted, // the core stays white). const headScale = Math.max(0.4, (this.boltWidth * 1.9) / TEX_SIZE); this.core = scene.add .image(o.x, o.y, TEX_KEY) .setScale(headScale) .setBlendMode(Phaser.BlendModes.ADD); assignWorld(scene, this.core); // world pass (the UI pass must not double-draw) this.angle0 = o.angle; // The head's tip in world px (texture head at BOLT_HEAD_X of BOLT_W). this.headDist = ((BOLT_W / 2) - (BOLT_W - BOLT_HEAD_X)) * (this.boltLength / BOLT_W); this.rotation = o.angle; this.setDepth(20); if (this.core && this.core.setDepth) this.core.setDepth(21); this._syncCore(); // World-space radius for the scene's circle checks (the old dot's ~3/10 // of its glow — the collision read is unchanged by the visual upgrade). const s = Math.max(0.05, size / TEX_SIZE); this.radius = TEX_SIZE * 0.3125 * s; // 10 px in texture space, at scale const body = this.body; body.setCircle(10); // texture-space radius (auto-scales with the sprite) body.setAllowGravity(false); body.setVelocity(Math.cos(o.angle) * (o.speed ?? 600), Math.sin(o.angle) * (o.speed ?? 600)); } /** Keep the white core on the head (the sprite is the bolt's CENTER). */ _syncCore() { if (!this.core) return; this.core.x = this.x + Math.cos(this.angle0) * this.headDist; this.core.y = this.y + Math.sin(this.angle0) * this.headDist; } /** Age the bullet (ms). Fizzles past its life. */ update(deltaMs) { this.life -= deltaMs / 1000; if (this.life <= 0) this.consume(); else this._syncCore(); } /** * Consume the bullet (a hit, or the end of its life). One-shot: only * the FIRST caller destroys it — the collision pass and the aging pass * can both land in the same frame. * @returns {boolean} true when THIS call consumed it */ consume() { if (this.dead) return false; this.dead = true; if (this.core) { if (typeof this.core.destroy === 'function') this.core.destroy(); this.core = null; } this.destroy(); return true; } /** Destroy the bolt (and its core — the system's destroy() skips consume). */ destroy(fromScene) { if (this.core) { if (typeof this.core.destroy === 'function') this.core.destroy(); this.core = null; } super.destroy(fromScene); } }