Add barrier system with edge traps and interior obstacles

- Introduce `BarrierManager` to handle neon cyberpunk-style barriers on screen edges and optional interior obstacles.
- Edge barriers blink in after a short delay, trap the player if they are inside when solidified (instant death + respawn), and explode when zone waves complete to allow exit.
- Interior barriers block enemies but not the player, adding tactical depth to arena layouts.
- Update `zones.json` with `interiorBarriers` config per zone.
- Adjust player/enemy sprite depths for correct rendering order relative to barriers.
- Integrate barrier collision into physics: player vs all barriers, enemies vs interior only; bullets destroyed on contact with any barrier.
- Refactor zone transitions and exit logic to coordinate barrier activation/destruction animations.
This commit is contained in:
Brian Fertig 2026-03-07 16:44:05 -07:00
parent 7da6459abe
commit 0a24272c88
6 changed files with 436 additions and 6 deletions

View File

@ -1,6 +1,7 @@
[
{
"id": 1,
"interiorBarriers": 0,
"waves": [
{ "enemies": [{ "type": "ChaseEnemy", "count": 5 }] },
{ "enemies": [{ "type": "ChaseEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 6 }] },
@ -9,6 +10,7 @@
},
{
"id": 2,
"interiorBarriers": 2,
"waves": [
{ "enemies": [{ "type": "ChaseEnemy", "count": 7 }, { "type": "SwarmerEnemy", "count": 8 }] },
{ "enemies": [{ "type": "ShooterEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 10 }] },

View File

@ -47,7 +47,7 @@ export class Player {
}
_buildSprite(x, y) {
this.sprite = this.scene.add.sprite(x, y, 'player');
this.sprite = this.scene.add.sprite(x, y, 'player').setDepth(6);
this.scene.physics.add.existing(this.sprite);
this.sprite.body.setCollideWorldBounds(true);
// Circle hitbox centered in the 48×48 sprite: radius 16, offset 8 from top-left

View File

@ -37,7 +37,7 @@ export class BaseEnemy {
}
_buildSprite(x, y) {
this.sprite = this.scene.add.sprite(x, y, 'enemies', this.frameOffset);
this.sprite = this.scene.add.sprite(x, y, 'enemies', this.frameOffset).setDepth(5);
this.scene.physics.add.existing(this.sprite);
// Circle hitbox centered in the 48×48 frame
const offset = 24 - this.radius;

View File

@ -5,6 +5,7 @@ import { SkillTree } from '../systems/SkillTree.js';
import { HUD } from '../ui/HUD.js';
import { SkillTreeUI } from '../ui/SkillTreeUI.js';
import { Reticle } from '../ui/Reticle.js';
import { BarrierManager } from '../systems/BarrierManager.js';
export class GameScene extends Phaser.Scene {
constructor() {
@ -57,9 +58,38 @@ export class GameScene extends Phaser.Scene {
this.reticle = new Reticle(this);
this._setupBarriers();
this.waveManager.start();
}
_setupBarriers() {
// Remove old colliders before destroying the groups they reference
if (this._barrierColliders) {
this._barrierColliders.forEach(c => c.destroy());
}
this.barrierManager?.destroy();
const zoneData = this.waveManager.currentZone ?? this.waveManager.zones[0];
const interiorCount = zoneData?.interiorBarriers ?? 0;
this.barrierManager = new BarrierManager(this, interiorCount);
// Player blocked by all barriers; enemies only blocked by interior ones
this._barrierColliders = [
this.physics.add.collider(this.player.sprite, this.barrierManager.staticGroup),
this.physics.add.collider(this.waveManager.spriteGroup, this.barrierManager.interiorStaticGroup),
];
this.barrierManager.activateEdgeBarriers(() => {
const W = this.scale.width;
const H = this.scale.height;
if (this.barrierManager.isInsideEdgeBarrier(this.player.x, this.player.y)) {
this.player.sprite.body.reset(W / 2, H / 2);
this.player.takeDamage(this.player.hp); // lose a life, respawn at center
}
});
}
_drawArena(W, H) {
// Dark background
this.add.rectangle(W / 2, H / 2, W, H, 0x111118);
@ -97,6 +127,13 @@ export class GameScene extends Phaser.Scene {
for (const bullet of bullets) {
if (!bullet.active) continue;
// Destroy bullet if it hits a barrier
if (this.barrierManager.isPointBlocked(bullet.x, bullet.y)) {
bullet.destroy();
continue;
}
for (const enemy of enemies) {
if (!enemy.active) continue;
const dist = Phaser.Math.Distance.Between(bullet.x, bullet.y, enemy.x, enemy.y);
@ -128,10 +165,12 @@ export class GameScene extends Phaser.Scene {
// ── Zone exit (Smash TV style) ─────────────────────────────────────────────
_startZoneExit() {
this._waitingForExit = true;
// Let the player walk off any edge
this.player.sprite.body.setCollideWorldBounds(false);
this._showExitArrows();
// Explode edge barriers first, then allow exit
this.barrierManager.explodeEdgeBarriers(() => {
this._waitingForExit = true;
this.player.sprite.body.setCollideWorldBounds(false);
this._showExitArrows();
});
}
_showExitArrows() {
@ -238,6 +277,8 @@ export class GameScene extends Phaser.Scene {
this.player.sprite.setPosition(spawnX, spawnY);
this.player.sprite.body.setCollideWorldBounds(true);
this.waveManager.startNextZone();
// Set up barriers AFTER zone index is incremented so we read the new zone's config
if (this.waveManager.currentZone) this._setupBarriers();
}
// ── Level up ───────────────────────────────────────────────────────────────
@ -289,7 +330,9 @@ export class GameScene extends Phaser.Scene {
shutdown() {
this.events.removeAllListeners();
this._barrierColliders?.forEach(c => c.destroy());
this.reticle?.destroy();
this.barrierManager?.destroy();
this._hideExitArrows();
this._waitingForExit = false;
this.player?.destroy();

View File

@ -0,0 +1,382 @@
// Cyberpunk building-facade barriers on screen edges + optional interior obstacles.
// Edge barriers blink in ~5s after zone start; explode when zone waves complete.
const EDGE_DEPTH = 3;
const INT_DEPTH = 3;
const NEON_CYAN = 0x00ffff;
const NEON_PINK = 0xff00aa;
const NEON_YELLOW = 0xffee00;
const WALL_FILL = 0x08080f;
const ACTIVATE_DELAY = 2000; // ms before edge barriers start blinking in
const BLINK_DURATION = 300; // ms per half-blink (yoyo)
const BLINK_REPEATS = 4; // total blink cycles (×2 for yoyo = 2.4s of blinking)
// Edge segments — 3 per side
function buildEdgeSegments(W, H) {
const T = 52;
const segs = [];
const topWidths = [Math.floor(W * 0.32), Math.floor(W * 0.36), W - Math.floor(W * 0.32) - Math.floor(W * 0.36)];
let ox = 0;
topWidths.forEach((w, i) => {
segs.push({ dir: 'top', x: ox + w / 2, y: T / 2, w, h: T, segIdx: i });
ox += w;
});
ox = 0;
topWidths.forEach((w, i) => {
segs.push({ dir: 'bottom', x: ox + w / 2, y: H - T / 2, w, h: T, segIdx: i });
ox += w;
});
const sideH = H - T * 2;
const leftHeights = [Math.floor(sideH * 0.33), Math.floor(sideH * 0.34), sideH - Math.floor(sideH * 0.33) - Math.floor(sideH * 0.34)];
let oy = T;
leftHeights.forEach((h, i) => {
segs.push({ dir: 'left', x: T / 2, y: oy + h / 2, w: T, h, segIdx: i });
oy += h;
});
oy = T;
leftHeights.forEach((h, i) => {
segs.push({ dir: 'right', x: W - T / 2, y: oy + h / 2, w: T, h, segIdx: i });
oy += h;
});
return segs;
}
// Pool of interior obstacle rects (picked from by index)
function allInteriorRects(W, H) {
const T = 52;
const p = { x: T, y: T, w: W - T * 2, h: H - T * 2 };
return [
{ x: p.x + p.w * 0.18, y: p.y + p.h * 0.20, w: 90, h: 24 },
{ x: p.x + p.w * 0.72, y: p.y + p.h * 0.22, w: 100, h: 24 },
{ x: p.x + p.w * 0.50, y: p.y + p.h * 0.42, w: 24, h: 90 },
{ x: p.x + p.w * 0.20, y: p.y + p.h * 0.68, w: 110, h: 24 },
{ x: p.x + p.w * 0.75, y: p.y + p.h * 0.70, w: 24, h: 80 },
{ x: p.x + p.w * 0.45, y: p.y + p.h * 0.80, w: 80, h: 24 },
];
}
const FLY_DIR = { top: [0, -1], bottom: [0, 1], left: [-1, 0], right: [1, 0] };
export class BarrierManager {
/**
* @param {Phaser.Scene} scene
* @param {number} interiorCount - how many interior obstacles to create (0 = none)
*/
constructor(scene, interiorCount = 0) {
this.scene = scene;
this._edgeSegments = [];
this._interiorSegs = [];
this._allRects = []; // all barrier rects for isPointBlocked
this._edgeRects = []; // edge-only rects for isInsideEdgeBarrier
this._glitchTimer = null;
this._glitchTargets = []; // only contains visible/active gfx
this._edgeActive = false;
this._init(interiorCount);
}
// ── Public API ─────────────────────────────────────────────────────────────
/** All barrier physics (player + bullet blocking). */
get staticGroup() { return this._staticGroup; }
/** Interior-only physics (enemy collision — enemies spawn outside edge barriers). */
get interiorStaticGroup() { return this._interiorStaticGroup; }
/** True if (x,y) is inside any barrier rect. */
isPointBlocked(x, y) {
for (const r of this._allRects) {
if (x >= r.x - r.w / 2 && x <= r.x + r.w / 2 &&
y >= r.y - r.h / 2 && y <= r.y + r.h / 2) return true;
}
return false;
}
/** True if (x,y) is inside any edge barrier rect (for trap check). */
isInsideEdgeBarrier(x, y) {
for (const r of this._edgeRects) {
if (x >= r.x - r.w / 2 && x <= r.x + r.w / 2 &&
y >= r.y - r.h / 2 && y <= r.y + r.h / 2) return true;
}
return false;
}
/**
* Animate edge barriers blinking in over ~5s, then enable their physics.
* @param {Function} onTrapped - called if player is inside an edge barrier when it solidifies
*/
activateEdgeBarriers(onTrapped) {
// Edge barriers start invisible and with physics disabled
this._edgeSegments.forEach(seg => {
seg.gfx.setAlpha(0);
if (seg.bodyImg?.body) seg.bodyImg.body.enable = false;
});
// Use a repeating time event to blink manually — more reliable on Graphics objects
let blinksLeft = BLINK_REPEATS * 2; // each tick toggles on/off
let visible = false;
const blinkEvent = this.scene.time.addEvent({
delay: BLINK_DURATION,
startAt: ACTIVATE_DELAY,
repeat: BLINK_REPEATS * 2 - 1,
callback: () => {
visible = !visible;
const a = visible ? 1 : 0;
this._edgeSegments.forEach(seg => { if (seg.gfx?.active) seg.gfx.setAlpha(a); });
blinksLeft--;
if (blinksLeft <= 0) {
// Final state: fully solid
this._edgeSegments.forEach(seg => {
if (seg.gfx?.active) seg.gfx.setAlpha(1);
if (seg.bodyImg?.body) seg.bodyImg.body.enable = true;
});
this._edgeActive = true;
this._glitchTargets.push(...this._edgeSegments.map(s => s.gfx).filter(g => g?.active));
if (onTrapped) onTrapped();
}
},
});
this._activateEvent = blinkEvent;
}
/**
* Explode edge barriers with animation, then call cb when done.
*/
explodeEdgeBarriers(cb) {
const scene = this.scene;
const W = scene.scale.width;
const H = scene.scale.height;
if (this._glitchTimer) { this._glitchTimer.remove(); this._glitchTimer = null; }
// Disable physics immediately
this._edgeSegments.forEach(seg => {
if (seg.bodyImg) { seg.bodyImg.destroy(); seg.bodyImg = null; }
seg.body = null;
});
// Camera shake + flash
scene.cameras.main.shake(350, 0.014);
const flash = scene.add.rectangle(W / 2, H / 2, W, H, 0xffffff).setDepth(80).setAlpha(0.55);
scene.tweens.add({ targets: flash, alpha: 0, duration: 250, onComplete: () => flash.destroy() });
let remaining = this._edgeSegments.length;
const onSegDone = () => { if (--remaining === 0 && cb) cb(); };
this._edgeSegments.forEach((seg, idx) => {
if (!seg.gfx?.active) { onSegDone(); return; }
const [dx, dy] = FLY_DIR[seg.dir];
const flyDist = seg.dir === 'top' || seg.dir === 'bottom' ? H * 0.7 : W * 0.7;
const delay = idx * 28 + Phaser.Math.Between(0, 40);
scene.time.delayedCall(delay, () => {
this._spawnDebris(seg);
scene.tweens.add({
targets: seg.gfx,
x: seg.gfx.x + dx * flyDist,
y: seg.gfx.y + dy * flyDist,
alpha: 0,
angle: Phaser.Math.Between(-25, 25),
duration: 480,
ease: 'Cubic.easeIn',
onComplete: () => { seg.gfx.destroy(); onSegDone(); },
});
});
});
}
destroy() {
if (this._glitchTimer) this._glitchTimer.remove();
if (this._activateEvent) this._activateEvent.remove();
this._edgeSegments.forEach(s => s.gfx?.destroy());
this._interiorSegs.forEach(s => s.gfx?.destroy());
this._staticGroup?.destroy(true);
this._interiorStaticGroup?.destroy(true);
}
// ── Init ───────────────────────────────────────────────────────────────────
_init(interiorCount) {
const scene = this.scene;
const W = scene.scale.width;
const H = scene.scale.height;
if (!scene.textures.exists('_barrier_px')) {
const pg = scene.make.graphics({ x: 0, y: 0, add: false });
pg.fillStyle(0xffffff, 1);
pg.fillRect(0, 0, 2, 2);
pg.generateTexture('_barrier_px', 2, 2);
pg.destroy();
}
this._staticGroup = scene.physics.add.staticGroup();
this._interiorStaticGroup = scene.physics.add.staticGroup();
buildEdgeSegments(W, H).forEach(def => {
const gfx = scene.add.graphics().setDepth(EDGE_DEPTH);
this._drawBarrier(gfx, def.w, def.h, def.dir, def.segIdx);
gfx.x = def.x;
gfx.y = def.y;
const bodyImg = this._makeBody(this._staticGroup, def.x, def.y, def.w, def.h);
this._edgeSegments.push({ gfx, bodyImg, body: bodyImg.body, dir: def.dir, rect: { x: def.x, y: def.y, w: def.w, h: def.h } });
this._allRects.push({ x: def.x, y: def.y, w: def.w, h: def.h });
this._edgeRects.push({ x: def.x, y: def.y, w: def.w, h: def.h });
});
const intPool = allInteriorRects(W, H);
for (let i = 0; i < Math.min(interiorCount, intPool.length); i++) {
const def = intPool[i];
const gfx = scene.add.graphics().setDepth(INT_DEPTH);
this._drawBarrier(gfx, def.w, def.h, null, 0);
gfx.x = def.x;
gfx.y = def.y;
this._makeBody(this._staticGroup, def.x, def.y, def.w, def.h);
this._makeBody(this._interiorStaticGroup, def.x, def.y, def.w, def.h);
this._interiorSegs.push({ gfx, rect: def });
this._allRects.push({ x: def.x, y: def.y, w: def.w, h: def.h });
this._glitchTargets.push(gfx);
}
this._startGlitch();
}
_makeBody(group, cx, cy, w, h) {
const img = group.create(cx, cy, '_barrier_px');
img.setVisible(false);
img.setDisplaySize(w, h);
img.refreshBody();
return img;
}
// ── Drawing ────────────────────────────────────────────────────────────────
_drawBarrier(g, w, h, dir, segIdx) {
const hw = w / 2, hh = h / 2;
g.fillStyle(WALL_FILL, 1);
g.fillRect(-hw, -hh, w, h);
g.lineStyle(2, NEON_CYAN, 0.85);
g.strokeRect(-hw, -hh, w, h);
const INS = 5;
g.lineStyle(1, NEON_CYAN, 0.35);
g.strokeRect(-hw + INS, -hh + INS, w - INS * 2, h - INS * 2);
if (dir) {
this._drawFacade(g, w, h, dir, segIdx);
} else {
this._drawInteriorDetail(g, w, h);
}
}
_drawFacade(g, w, h, dir, segIdx) {
const hw = w / 2, hh = h / 2;
const isHoriz = dir === 'top' || dir === 'bottom';
g.fillStyle(NEON_PINK, 0.18);
if (isHoriz) g.fillRect(-hw + 8, -3, w - 16, 6);
else g.fillRect(-3, -hh + 8, 6, h - 16);
g.fillStyle(NEON_YELLOW, 0.25);
const WIN_W = isHoriz ? 14 : 10;
const WIN_H = isHoriz ? 10 : 14;
const cols = isHoriz ? 4 + segIdx : 2;
const rows = isHoriz ? 2 : 3 + segIdx;
const xGap = (w - 20) / (cols + 1);
const yGap = (h - 10) / (rows + 1);
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
g.fillRect(-hw + 10 + xGap * c - WIN_W / 2, -hh + 5 + yGap * r - WIN_H / 2, WIN_W, WIN_H);
}
}
const L = 10;
g.lineStyle(2, NEON_PINK, 0.8);
[[-hw, -hh], [hw, -hh], [-hw, hh], [hw, hh]].forEach(([cx, cy]) => {
const sx = cx < 0 ? 1 : -1;
const sy = cy < 0 ? 1 : -1;
g.beginPath();
g.moveTo(cx + sx * L, cy);
g.lineTo(cx, cy);
g.lineTo(cx, cy + sy * L);
g.strokePath();
});
}
_drawInteriorDetail(g, w, h) {
const hw = w / 2, hh = h / 2;
const step = Math.min(w, h) > 50 ? 16 : 12;
g.lineStyle(1, NEON_PINK, 0.3);
for (let i = -Math.max(w, h); i < Math.max(w, h); i += step) {
g.beginPath();
g.moveTo(Math.max(-hw, i - hh), Math.min(hh, i + hw));
g.lineTo(Math.min(hw, i + hh), Math.max(-hh, i - hw));
g.strokePath();
}
g.fillStyle(NEON_CYAN, 0.6);
g.fillRect(-3, -3, 6, 6);
}
// ── Glitch ─────────────────────────────────────────────────────────────────
_startGlitch() {
this._glitchTimer = this.scene.time.addEvent({
delay: 800,
loop: true,
callback: () => {
if (!this._glitchTargets.length || Math.random() > 0.45) return;
const target = Phaser.Utils.Array.GetRandom(this._glitchTargets);
if (!target?.active) return;
this.scene.tweens.add({
targets: target,
alpha: { from: 1, to: 0.2 },
duration: 60,
yoyo: true,
repeat: Phaser.Math.Between(1, 3),
});
},
});
}
// ── Explosion debris ────────────────────────────────────────────────────────
_spawnDebris({ rect, dir }) {
const scene = this.scene;
const colors = [NEON_CYAN, NEON_PINK, NEON_YELLOW];
const [dx, dy] = FLY_DIR[dir];
for (let i = 0; i < Phaser.Math.Between(6, 12); i++) {
const piece = scene.add.rectangle(
rect.x + Phaser.Math.Between(-rect.w / 2, rect.w / 2),
rect.y + Phaser.Math.Between(-rect.h / 2, rect.h / 2),
Phaser.Math.Between(4, 18),
Phaser.Math.Between(4, 14),
Phaser.Utils.Array.GetRandom(colors),
).setDepth(60).setAlpha(0.9);
const speed = Phaser.Math.Between(80, 300);
scene.tweens.add({
targets: piece,
x: piece.x + dx * speed + Phaser.Math.Between(-120, 120),
y: piece.y + dy * speed + Phaser.Math.Between(-120, 120),
alpha: 0, scaleX: 0.1, scaleY: 0.1,
angle: Phaser.Math.Between(-180, 180),
duration: Phaser.Math.Between(350, 700),
ease: 'Cubic.easeOut',
onComplete: () => piece.destroy(),
});
}
}
}

View File

@ -14,6 +14,7 @@ export class WaveManager {
this.waveIndex = 0;
this.enemies = [];
this.active = false;
this.spriteGroup = scene.physics.add.group();
}
load(data) {
@ -44,6 +45,7 @@ export class WaveManager {
const [x, y] = this._edgePosition();
const enemy = new EnemyClass(this.scene, x, y, this.player);
this.enemies.push(enemy);
this.spriteGroup.add(enemy.sprite);
}
});
}
@ -104,6 +106,7 @@ export class WaveManager {
reset() {
this.enemies.forEach(e => { if (e.active) e.destroy(); });
this.enemies = [];
this.spriteGroup.clear(false, false);
this.zoneIndex = 0;
this.waveIndex = 0;
this.active = false;