orbit/js/combat/Bullet.js

185 lines
7.2 KiB
JavaScript

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