185 lines
7.2 KiB
JavaScript
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);
|
|
}
|
|
}
|