Prepare Void Bastion alpha 7 release

This commit is contained in:
Emporex committed 2026-10-06 23:59:12 +02:00
1 parent 56077e88a5
commit e63e08ddde
18 files changed
+799 -95

No files matched your search

+88 -22
View File
@@ -17,14 +17,24 @@ var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
var PATHS = [LEFT_PATH, RIGHT_PATH];
var TOWERS = [
{cost:120,damage:22,range:190,cooldown:0.55},
{cost:180,damage:12,range:165,cooldown:0.18},
{cost:250,damage:65,range:260,cooldown:1.40},
{cost:230,damage:48,range:240,cooldown:1.05},
{cost:210,damage:34,range:215,cooldown:0.92},
{cost:300,damage:110,range:330,cooldown:1.85}
{cost:120,damage:22,range:190,cooldown:0.55,targetClass:"ground"},
{cost:180,damage:12,range:165,cooldown:0.18,targetClass:"both",bothScale:0.82},
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
{category:"air",hp:185,speed:72,reward:34,breachDamage:2},
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
{category:"air",hp:740,speed:48,reward:108,breachDamage:4},
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
];
var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}];
var DIFFICULTIES = [
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
@@ -231,7 +241,7 @@ function matchmakerMatched(ctx, logger, nk, matches) {
function initialState(params) {
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
var credits = DIFFICULTIES[difficulty].startCredits;
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
}
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
@@ -247,7 +257,7 @@ function snapshot(state,tick) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,towers:state.towers,enemies:state.enemies};
return {version:GAME_VERSION, tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:state.towers,enemies:state.enemies};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
@@ -310,16 +320,58 @@ function handleSell(state,slot,payload) {
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
}
function enemyTypeForWave(wave,roll,difficulty,boss) {
if (boss) return wave>=10?8:5;
var offset=difficulty===2?2:(difficulty===1?1:0);
if (wave>=10&&roll>=0.96) return 8;
if (wave>=8&&roll>=0.84&&roll<0.94) return 7;
if (wave>=3&&roll>=0.52&&roll<0.64) return 6;
if (wave>=10-offset&&roll>=0.94) return 5;
if (wave>=7-offset&&roll>=0.80) return 4;
if (wave>=4-offset&&roll>=0.65) return 3;
if (wave>=7&&roll>=0.45) return 2;
if (wave>=3&&roll>=0.28) return 1;
return 0;
}
function wavePopulation(state,wave) {
var scale=1+Math.max(0,wave-3)*0.08;
if (state.difficulty===2) scale+=Math.max(0,wave-4)*0.025;
return Math.ceil((7+wave*2)*DIFFICULTIES[state.difficulty].count*scale)*2;
}
function buildWaveSchedule(state,wave,count) {
var schedule=[],bossCount=wave>0&&wave%5===0?2:0;
for (var i=0;i<count;i++) {
var boss=i>=count-bossCount;
var roll=((i+1)*0.61803398875+wave*0.38196601125)%1;
schedule.push({type:enemyTypeForWave(wave,roll,state.difficulty,boss),boss:boss});
}
return schedule;
}
function startWave(state) {
if (state.waveActive||state.gameOver||!state.started) return false;
state.wave+=1; state.spawnRemaining=Math.ceil((7+state.wave*2)*DIFFICULTIES[state.difficulty].count)*2; state.spawnTimer=0.1; state.waveActive=true; return true;
state.wave+=1; state.waveSchedule=buildWaveSchedule(state,state.wave,wavePopulation(state,state.wave)); state.spawnRemaining=state.waveSchedule.length; state.spawnTimer=0.1; state.waveActive=true; return true;
}
function spawnEnemy(state) {
var boss=state.wave>0&&state.wave%5===0&&state.spawnRemaining<=2,roll=Math.random();
var type=boss?2:(state.wave>=7&&roll>0.82?2:(state.wave>=3&&roll>0.53?1:0));
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty],scale=(1+Math.max(0,state.wave-1)*0.115)*(boss?3.25:1)*d.hp;
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
var boss=entry.boss,type=entry.type;
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty];
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss});
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
}
function applyDamageToEnemy(enemy,damage,towerType) {
var applied=damage;
if (enemy.shield) {
if (towerType===1) {
var shieldDamage=Math.min(enemy.shield_hp,damage);
enemy.shield_hp-=shieldDamage;
if (enemy.shield_hp<=0) { enemy.shield=false; applied=damage-shieldDamage; }
else applied=0;
} else if (towerType!==5) applied*=enemy.shield_resistance||0.28;
}
enemy.hp-=applied;
}
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
function updateEnemies(state,delta) {
@@ -328,24 +380,38 @@ function updateEnemies(state,delta) {
e.angle=Math.atan2(dy,dx);
if (dist<=Math.max(9,e.speed*delta)) {
e.x=target[0]; e.y=target[1]; e.path_index+=1;
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(1+e.type+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(ENEMIES[e.type].breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
}
}
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) {
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1;
for (var j=0;j<state.enemies.length;j++) if (distanceSquared(t.x,t.y,state.enemies[j].x,state.enemies[j].y)<=range*range) { var p=enemyProgress(state.enemies[j]); if (p>best) { best=p; targetIndex=j; } }
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
var candidate=state.enemies[j];
if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[candidate.type].category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
}
}
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
var damage=towerDamage(t);
if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:0.38);
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
var railTarget=state.enemies[railIndex];
if (distanceSquared(t.x,t.y,railTarget.x,railTarget.y)>rangeSquared) continue;
var offsetX=railTarget.x-t.x,offsetY=railTarget.y-t.y,along=offsetX*dx+offsetY*dy,across=Math.abs(offsetX*dy-offsetY*dx);
if (along>=0&&along<=range&&across<=18) { applyDamageToEnemy(railTarget,damage,t.type); if (railTarget.hp<=0) removeEnemy(state,railIndex,t.owner); }
}
} else if (t.type===2||t.type===3||t.type===4) {
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
var tx=target.x,ty=target.y,targetId=target.id;
for (var k=state.enemies.length-1;k>=0;k--) if (distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*splash; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
for (var k=state.enemies.length-1;k>=0;k--) if ((TOWERS[t.type].targetClass==="both"||TOWERS[t.type].targetClass===ENEMIES[state.enemies[k].type].category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
t.cooldown=towerCooldown(t);
}
}
+59
View File
@@ -8,6 +8,62 @@ const context = { console, Math, Date, JSON, isFinite };
vm.createContext(context);
vm.runInContext(source, context);
assert.strictEqual(context.ENEMIES.length, 9);
assert.strictEqual(context.ENEMIES[0].category, "air");
assert.strictEqual(context.ENEMIES[6].category, "ground");
assert.strictEqual(context.TOWERS[0].targetClass, "ground");
assert.strictEqual(context.TOWERS[1].targetClass, "both");
assert.ok(context.TOWERS[1].bothScale < 1, "The all-purpose laser should trade damage for shield-breaking ground coverage.");
assert.strictEqual(context.TOWERS[3].targetClass, "both");
assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage for flexibility.");
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
const shieldTest = { hp: 100, shield: true, shield_hp: 30, max_shield_hp: 30, shield_resistance: 0.28 };
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.hp, 100, "Laser damage should drain shield before hull.");
assert.strictEqual(shieldTest.shield_hp, 10);
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.shield, false);
assert.strictEqual(shieldTest.hp, 90, "Laser damage beyond shield capacity should spill into hull.");
const priorityState = {
towers: [{ id: 1, type: 1, x: 800, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 201, type: 6, x: 850, y: 120, path_index: 1, hp: 100, shield: true, shield_hp: 50, max_shield_hp: 50, shield_resistance: 0.28 },
{ id: 202, type: 0, x: 950, y: 120, path_index: 1, hp: 100, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(priorityState, 1);
assert.ok(priorityState.enemies[0].shield_hp < 50, "Online lasers should drain a shield before firing at unshielded units.");
assert.strictEqual(priorityState.enemies[1].hp, 100);
const railgunState = {
towers: [{ id: 10, type: 5, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 301, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 302, type: 6, x: 500, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 303, type: 0, x: 450, y: 150, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(railgunState, 0.01);
assert.ok(railgunState.enemies[0].hp < 1000 && railgunState.enemies[1].hp < 1000, "Online railgun should pierce enemies behind its first hit on the same line.");
assert.strictEqual(railgunState.enemies[2].hp, 1000, "Online railgun should not hit units away from its beam line.");
const empState = {
towers: [{ id: 11, type: 4, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 401, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 402, type: 6, x: 450, y: 120, path_index: 1, hp: 1000, shield: false },
{ id: 403, type: 6, x: 570, y: 120, path_index: 1, hp: 1000, shield: false }
],
credits: [0, 0], wave: 1
};
context.updateTowers(empState, 0.01);
assert.strictEqual(empState.enemies[0].hp, 980, "EMP should deal 20 damage to its primary target.");
assert.strictEqual(empState.enemies[1].hp, 980, "EMP should deal its reduced 20 damage to nearby ground units too.");
assert.strictEqual(empState.enemies[2].hp, 1000, "EMP should not damage units outside its area radius.");
const sent = [];
const dispatcher = {
broadcastMessage(opCode, data) {
@@ -132,6 +188,9 @@ assert.strictEqual(state.credits[1], 250);
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
assert.strictEqual(state.wave, 1);
assert.strictEqual(state.waveActive, true);
assert.strictEqual(context.snapshot(state, 6).wave_types_remaining.length, state.spawnRemaining);
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));