Galaxy Wing — Weapon System + Module Split Retrospective
From single weapon with 5 power levels to 5×7 weapons + 5 subweapons, plus splitting a 1035-line logic.ts into 8 modules
Where We Started — 5-Way Was the Cap
Galaxy Wing originally had a single weapon with 5 power-up tiers. The most you could see was a 5-way fan, and there wasn't much progression to discover. I decided to expand to 7-tier deeper evolution + weapon type switching + subweapons all in one pass.
5 type × 7 level — Branching + Depth
I defined five types — spread / laser / wave / plasma / charge — and packed all 7-level evolution data into a single object.
export const WEAPON_DEFS: Record<WeaponType, readonly WeaponLevelDef[]> = {
spread: [
/* 7 levels */
],
laser: [
/* 7 levels */
],
wave: [
/* 7 levels */
],
plasma: [
/* 7 levels */
],
charge: [
/* 7 levels */
],
};
Each level defines angles / offsets / color / radius / damage / speed / fireInterval, plus optional type-specific fields (piercing / waveAmplitude / plasmaSplash / chargeTime). With this data-driven approach, firePlayer collapsed into a single spawn loop.
Drop kinds split into 'P' (level up within type) and 'W' (cycle to next type, restart at level 1).
Charge — Hold-Release Mechanism
The charge type has a separate input flow: hold Z to charge, release to fire.
function handleChargeWeapon(state, input) {
if (input.fire) {
if (p.chargeStartFrame === -1 && state.frame >= p.nextFireFrame) {
p.chargeStartFrame = state.frame;
}
} else if (p.chargeStartFrame !== -1) {
const fullyCharged = state.frame - p.chargeStartFrame >= def.chargeTime;
if (fullyCharged) spawnPlayerBullets(state, def, p.x, p.y, 1.5);
else {
/* weak single shot */
}
p.chargeStartFrame = -1;
}
}
The tricky part was resetting charge state when EMP stun hit. Visual feedback is a purple ring around the player, pulsing when fully charged.
Special Bullets — The Pool-Reuse Stale Trap
The bullet pool shares a single interface across all kinds — spread / laser / wave / plasma / missile / mine all acquire the same Bullet object. This caused a bug: if the previous shot from a slot was plasma, plasmaSplash stayed positive on the recycled object, and the next spread shot would explode in splash.
Fix: spawnPlayerBullets explicitly resets every special field.
b.piercing = def.piercing ?? false;
b.waveAmplitude = def.waveAmplitude; // undefined → undefined
b.plasmaSplash = def.plasmaSplash;
b.lastHitIdx = undefined;
b.homingTurnRate = undefined;
b.mineExplosionRadius = undefined;
b.mineExplodeFrame = undefined;
Explicit undefined assignment is the key to preventing pool-reuse stale state.
5 Subweapons — Single Slot + Cycle
I added a single-slot subweapon system separate from the main weapon.
| Type | Effect | Cooldown |
|---|---|---|
| missile | Homing + plasma splash | 1.5s |
| empBurst | Clear enemy bullets + 60f stun | 12s |
| lightning | Instant chain (1 hop) | 4s |
| drone | 10s side-slot auto-fire | 12s |
| spreadMine | Proximity/lifetime → 8-way burst | 6s |
'S' drop cycles types (empty slot → first, otherwise → next). C key fires. On mobile, a SUB button sits next to the bomb button, with cooldown shown as a conic-gradient mask.
.subBtnCooldown {
background: conic-gradient(rgba(0, 0, 0, 0.55) calc(var(--sub-cd) * 360deg), transparent 0);
}
--sub-cd slides 0→1 to sweep the mask clockwise.
logic.ts 1035 → 8 Modules
Adding all this swelled galaxyWingLogic.ts to 1035 lines. Input / player / charge / subweapons / wave dispatch / spawn patterns / enemy AI / bullets / collision / power-ups / combo were all in one file. I drew the dependency graph to make sure splits were one-directional.
score (root)
↓
explode → subweaponLogic, player → collision, update → logic
↘ spawn (independent)
↘ types (subweapons type only)
damagePlayer is only called by collision but lives in player module (semantic fit), and findNearestActiveEnemy is shared by lightning and missile homing — exported from subweaponLogic and imported by update.
After split, each file landed at 100~250 lines, and the 1:1 mapping of all 45 functions was preserved. I verified by comparing function inventories before/after and running tsc --noEmit.
Renderer got similar treatment — 816 → 98 lines, with drawPlayer / drawEnemies / drawBullets / drawHUD / drawPowerUps / drawParticles split into 6 files.
Wrap-Up
5×7 weapons + 5 subweapons + 16-module split, all in one cycle. Thanks to the data-driven weapon definition, adding a new weapon type means just one row in WEAPON_DEFS. Subweapons are similar — SUBWEAPON_DEFS plus one branch.
The biggest takeaway: don't miss the split moment. Should have split around 500 lines instead of letting it ride to 1035.
Guestbook
Leave a short note about this post
Loading...