Initial Void Bastion 0.20 prototype
This commit is contained in:
commit
7362ca0f02
251 files changed
+29688
No files matched your search
@@ -0,0 +1,8 @@
|
||||
POSTGRES_PASSWORD=GENERATE_A_RANDOM_HEX_VALUE
|
||||
NAKAMA_SERVER_KEY=voidbastion-public-v1
|
||||
NAKAMA_SESSION_KEY=GENERATE_A_RANDOM_HEX_VALUE
|
||||
NAKAMA_REFRESH_KEY=GENERATE_A_RANDOM_HEX_VALUE
|
||||
NAKAMA_HTTP_KEY=GENERATE_A_RANDOM_HEX_VALUE
|
||||
NAKAMA_CONSOLE_PASSWORD=GENERATE_A_RANDOM_HEX_VALUE
|
||||
NAKAMA_CONSOLE_USERNAME=gergames-admin
|
||||
NAKAMA_CONSOLE_SIGNING_KEY=GENERATE_A_RANDOM_HEX_VALUE
|
||||
@@ -0,0 +1,29 @@
|
||||
# Void Bastion Online-Backend
|
||||
|
||||
Dieser Docker-Compose-Stack betreibt eine eigene Nakama-Instanz mit einer ausschließlich intern erreichbaren PostgreSQL-Datenbank.
|
||||
|
||||
## Unraid-Pfade
|
||||
|
||||
- Stack: `/mnt/user/appdata/void-bastion-online/stack`
|
||||
- Nakama-Daten und spätere Servermodule: `/mnt/user/appdata/void-bastion-online/nakama`
|
||||
- PostgreSQL-Daten: `/mnt/user/appdata/void-bastion-online/postgres`
|
||||
- tägliche Datenbanksicherungen: `/mnt/user/appdata/void-bastion-online/backups`
|
||||
- Geheimnisse: `/mnt/user/appdata/void-bastion-online/stack/.env`
|
||||
|
||||
Die echte `.env`-Datei bleibt ausschließlich auf dem Server und darf weder in das Spiel noch in ein Quellarchiv aufgenommen werden.
|
||||
|
||||
Der Backup-Container erstellt beim Start und danach täglich einen komprimierten PostgreSQL-Dump. Sicherungen werden 14 Tage aufbewahrt. Für einen vollständigen Schutz sollte dieses Verzeichnis zusätzlich durch das vorhandene externe Unraid-Backup erfasst werden.
|
||||
|
||||
## Erreichbarkeit
|
||||
|
||||
- Spiel-API und Echtzeit-WebSocket: Host-Port `7350`
|
||||
- Verwaltungskonsole: nur auf Unraid-Loopback `127.0.0.1:7351`
|
||||
- PostgreSQL: kein veröffentlichter Host-Port
|
||||
|
||||
Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Manager mit einer eigenen Domain und TLS als HTTPS/WSS veröffentlicht. Die Verwaltungskonsole bleibt privat.
|
||||
|
||||
Öffentliche Adresse: `api.void-bastion.gergames.de`. Der Host `api.void-bastion` zeigt als CNAME auf `bs6o73mrdi3e60p0.myfritz.net`; Nginx Proxy Manager stellt HTTPS/WSS mit einem automatisch erneuerten Let’s-Encrypt-Zertifikat bereit und leitet intern auf Nakama an Port 7350 weiter.
|
||||
|
||||
## Betrieb
|
||||
|
||||
Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. Vor einem Versionswechsel werden Datenbank und Nakama-Appdata gesichert und die offiziellen Migrationshinweise geprüft.
|
||||
@@ -0,0 +1,101 @@
|
||||
name: void-bastion-online
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: void-bastion-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: nakama
|
||||
POSTGRES_USER: nakama
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- /mnt/user/appdata/void-bastion-online/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U nakama -d nakama"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
networks:
|
||||
- backend
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
nakama:
|
||||
image: registry.heroiclabs.com/heroiclabs/nakama:3.37.0
|
||||
container_name: void-bastion-nakama
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
NAKAMA_SERVER_KEY: ${NAKAMA_SERVER_KEY}
|
||||
NAKAMA_SESSION_KEY: ${NAKAMA_SESSION_KEY}
|
||||
NAKAMA_REFRESH_KEY: ${NAKAMA_REFRESH_KEY}
|
||||
NAKAMA_HTTP_KEY: ${NAKAMA_HTTP_KEY}
|
||||
NAKAMA_CONSOLE_PASSWORD: ${NAKAMA_CONSOLE_PASSWORD}
|
||||
NAKAMA_CONSOLE_USERNAME: ${NAKAMA_CONSOLE_USERNAME}
|
||||
NAKAMA_CONSOLE_SIGNING_KEY: ${NAKAMA_CONSOLE_SIGNING_KEY}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
/nakama/nakama migrate up --database.address "nakama:$${POSTGRES_PASSWORD}@postgres:5432/nakama" &&
|
||||
exec /nakama/nakama \
|
||||
--name void-bastion \
|
||||
--database.address "nakama:$${POSTGRES_PASSWORD}@postgres:5432/nakama" \
|
||||
--logger.level INFO \
|
||||
--session.token_expiry_sec 7200 \
|
||||
--socket.server_key "$${NAKAMA_SERVER_KEY}" \
|
||||
--session.encryption_key "$${NAKAMA_SESSION_KEY}" \
|
||||
--session.refresh_encryption_key "$${NAKAMA_REFRESH_KEY}" \
|
||||
--runtime.http_key "$${NAKAMA_HTTP_KEY}" \
|
||||
--runtime.js_entrypoint "build/index.js" \
|
||||
--console.username "$${NAKAMA_CONSOLE_USERNAME}" \
|
||||
--console.signing_key "$${NAKAMA_CONSOLE_SIGNING_KEY}" \
|
||||
--console.password "$${NAKAMA_CONSOLE_PASSWORD}"
|
||||
volumes:
|
||||
- /mnt/user/appdata/void-bastion-online/nakama:/nakama/data
|
||||
ports:
|
||||
- "7350:7350"
|
||||
- "127.0.0.1:7351:7351"
|
||||
healthcheck:
|
||||
test: ["CMD", "/nakama/nakama", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
networks:
|
||||
- backend
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
postgres-backup:
|
||||
image: postgres:16-alpine
|
||||
container_name: void-bastion-postgres-backup
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
while true; do
|
||||
pg_dump -h postgres -U nakama -d nakama -Fc -f "/backups/nakama-$$(date +%Y%m%d-%H%M%S).dump"
|
||||
find /backups -type f -name 'nakama-*.dump' -mtime +14 -delete
|
||||
sleep 86400
|
||||
done
|
||||
volumes:
|
||||
- /mnt/user/appdata/void-bastion-online/backups:/backups
|
||||
networks:
|
||||
- backend
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
backend:
|
||||
name: void-bastion-backend
|
||||
driver: bridge
|
||||
@@ -0,0 +1,25 @@
|
||||
BEGIN IMMEDIATE;
|
||||
|
||||
INSERT INTO certificate (
|
||||
id, created_on, modified_on, owner_user_id, is_deleted, provider,
|
||||
nice_name, domain_names, expires_on, meta
|
||||
) VALUES (
|
||||
58, datetime('now'), datetime('now'), 1, 0, 'letsencrypt',
|
||||
'api.void-bastion.gergames.de', '["api.void-bastion.gergames.de"]',
|
||||
datetime('now', '+89 days'), '{"nginx_online":true,"nginx_err":null}'
|
||||
);
|
||||
|
||||
INSERT INTO proxy_host (
|
||||
id, created_on, modified_on, owner_user_id, is_deleted, domain_names,
|
||||
forward_host, forward_port, access_list_id, certificate_id, ssl_forced,
|
||||
caching_enabled, block_exploits, advanced_config, meta,
|
||||
allow_websocket_upgrade, http2_support, forward_scheme, enabled,
|
||||
locations, hsts_enabled, hsts_subdomains, trust_forwarded_proto
|
||||
) VALUES (
|
||||
15, datetime('now'), datetime('now'), 1, 0,
|
||||
'["api.void-bastion.gergames.de"]', '192.168.178.20', 7350,
|
||||
0, 58, 1, 0, 1, '', '{"nginx_online":true,"nginx_err":null}',
|
||||
1, 1, 'http', 1, '[]', 0, 0, 0
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
# Temporary HTTP configuration used for the first ACME challenge.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name api.void-bastion.gergames.de;
|
||||
|
||||
include conf.d/include/letsencrypt-acme-challenge.conf;
|
||||
|
||||
access_log /data/logs/proxy-host-15_access.log proxy;
|
||||
error_log /data/logs/proxy-host-15_error.log warn;
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Managed-compatible HTTPS proxy for Nakama HTTP and WebSocket traffic.
|
||||
map $scheme $hsts_header {
|
||||
https "max-age=63072000; preload";
|
||||
}
|
||||
|
||||
server {
|
||||
set $forward_scheme http;
|
||||
set $server "192.168.178.20";
|
||||
set $port 7350;
|
||||
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name api.void-bastion.gergames.de;
|
||||
|
||||
http2 on;
|
||||
|
||||
include conf.d/include/letsencrypt-acme-challenge.conf;
|
||||
include conf.d/include/ssl-cache.conf;
|
||||
include conf.d/include/ssl-ciphers.conf;
|
||||
ssl_certificate /etc/letsencrypt/live/npm-58/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/npm-58/privkey.pem;
|
||||
|
||||
include conf.d/include/block-exploits.conf;
|
||||
|
||||
set $trust_forwarded_proto "F";
|
||||
include conf.d/include/force-ssl.conf;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
access_log /data/logs/proxy-host-15_access.log proxy;
|
||||
error_log /data/logs/proxy-host-15_error.log warn;
|
||||
|
||||
location / {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_http_version 1.1;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
include /data/nginx/custom/server_proxy[.]conf;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
var GAME_VERSION = "0.20";
|
||||
var MAP_COUNT = 3;
|
||||
var DIFFICULTY_COUNT = 3;
|
||||
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
||||
var MATCH_MODULE = "void_bastion_coop";
|
||||
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
|
||||
var TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
|
||||
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758;
|
||||
var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
|
||||
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}
|
||||
];
|
||||
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},
|
||||
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
|
||||
];
|
||||
|
||||
function clampInteger(value, minimum, maximum) {
|
||||
var parsed = Math.floor(Number(value));
|
||||
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
|
||||
return parsed;
|
||||
}
|
||||
function leaderboardId(mapIndex, difficulty, mode) { return mode + "_map" + mapIndex + "_difficulty" + difficulty; }
|
||||
function rpcServerInfo(ctx, logger, nk, payload) {
|
||||
return JSON.stringify({game:"void_bastion",version:GAME_VERSION,leaderboards:MAP_COUNT*DIFFICULTY_COUNT*VALID_MODES.length,authoritative_multiplayer:true,status:"online"});
|
||||
}
|
||||
function rpcSubmitRun(ctx, logger, nk, payload) {
|
||||
if (!ctx.userId) throw new Error("authentication required");
|
||||
var run;
|
||||
try { run = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
|
||||
var mapIndex = clampInteger(run.map,0,MAP_COUNT-1), difficulty = clampInteger(run.difficulty,0,DIFFICULTY_COUNT-1);
|
||||
var mode = String(run.mode || "");
|
||||
if (VALID_MODES.indexOf(mode) < 0) throw new Error("invalid game mode");
|
||||
var wave = clampInteger(run.wave,1,mode === "mission" ? 15 : 9999), score = clampInteger(run.score,0,1000000000);
|
||||
if (score > wave*5000+100000) throw new Error("implausible score");
|
||||
var id = leaderboardId(mapIndex,difficulty,mode);
|
||||
var metadata = {map:mapIndex,difficulty:difficulty,mode:mode,wave:wave,clientVersion:String(run.client_version||"unknown"),submittedAt:Date.now()};
|
||||
var record = nk.leaderboardRecordWrite(id,ctx.userId,ctx.username||"Pilot",score,wave,metadata,null);
|
||||
return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave});
|
||||
}
|
||||
|
||||
function matchmakerMatched(ctx, logger, nk, matches) {
|
||||
var difficulty = 1;
|
||||
if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty))));
|
||||
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
|
||||
}
|
||||
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:{},started:false,gameOver:false,difficulty:difficulty,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
|
||||
}
|
||||
function matchInit(ctx,logger,nk,params) { return {state:initialState(params||{}),tickRate:TICK_RATE,label:JSON.stringify({game:"void_bastion",mode:"coop",open:true})}; }
|
||||
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; }
|
||||
function firstFreeSlot(state) {
|
||||
var used={},ids=Object.keys(state.slots);
|
||||
for (var i=0;i<ids.length;i++) used[state.slots[ids[i]]]=true;
|
||||
return used[0] ? 1 : 0;
|
||||
}
|
||||
function snapshot(state,tick) {
|
||||
return {version:GAME_VERSION,tick:tick,started:state.started,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,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};
|
||||
}
|
||||
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); }
|
||||
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
|
||||
for (var i=0;i<presences.length;i++) { var p=presences[i]; state.presences[p.userId]=p; if (state.slots[p.userId]===undefined) state.slots[p.userId]=firstFreeSlot(state); }
|
||||
state.started=Object.keys(state.presences).length===2;
|
||||
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
|
||||
broadcastSnapshot(dispatcher,state,tick);
|
||||
return {state:state};
|
||||
}
|
||||
function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
|
||||
for (var i=0;i<presences.length;i++) delete state.presences[presences[i].userId];
|
||||
state.started=false; broadcastEvent(dispatcher,"partner_left",{}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
||||
}
|
||||
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
|
||||
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
|
||||
function towerRange(t) { return TOWERS[t.type].range*(1+(t.level-1)*0.08); }
|
||||
function towerCooldown(t) { return TOWERS[t.type].cooldown/(1+(t.level-1)*0.12); }
|
||||
function upgradeCost(t) { return Math.floor(TOWERS[t.type].cost*(0.72+t.level*0.28)); }
|
||||
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
|
||||
function distanceToSegment(px,py,ax,ay,bx,by) {
|
||||
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
|
||||
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
|
||||
}
|
||||
function validBuildPosition(state,slot,x,y) {
|
||||
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
|
||||
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
|
||||
if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
|
||||
if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
|
||||
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
|
||||
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
|
||||
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
|
||||
return true;
|
||||
}
|
||||
function handleBuild(state,slot,payload) {
|
||||
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
|
||||
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<TOWERS[type].cost) return false;
|
||||
state.credits[slot]-=TOWERS[type].cost;
|
||||
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
|
||||
}
|
||||
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
|
||||
function handleUpgrade(state,slot,payload) {
|
||||
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
|
||||
if (i<0||state.towers[i].level>=3) return false;
|
||||
var cost=upgradeCost(state.towers[i]); if (state.credits[slot]<cost) return false;
|
||||
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
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 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});
|
||||
}
|
||||
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
|
||||
function updateEnemies(state,delta) {
|
||||
for (var i=state.enemies.length-1;i>=0;i--) {
|
||||
var e=state.enemies[i],route=PATHS[e.route_id],target=route[e.path_index],dx=target[0]-e.x,dy=target[1]-e.y,dist=Math.sqrt(dx*dx+dy*dy);
|
||||
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; } }
|
||||
} 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; } }
|
||||
if (targetIndex<0) continue;
|
||||
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+(t.type===0?Math.PI:0);
|
||||
if (t.cooldown>0) continue;
|
||||
var damage=towerDamage(t);
|
||||
if (t.type===2) {
|
||||
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)<=76*76) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*0.45; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
||||
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
||||
t.cooldown=towerCooldown(t);
|
||||
}
|
||||
}
|
||||
function updateWave(state,delta,dispatcher) {
|
||||
if (!state.waveActive||state.gameOver) return;
|
||||
if (state.spawnRemaining>0) {
|
||||
state.spawnTimer-=delta;
|
||||
if (state.spawnTimer<=0) { spawnEnemy(state); state.spawnRemaining-=1; state.spawnTimer=Math.max(0.24,(0.82-state.wave*0.025)*DIFFICULTIES[state.difficulty].spawnDelay); }
|
||||
} else if (!state.enemies.length) {
|
||||
state.waveActive=false; var bonus=Math.floor((55+state.wave*8)*DIFFICULTIES[state.difficulty].income); state.credits[0]+=bonus; state.credits[1]+=bonus; broadcastEvent(dispatcher,"wave_complete",{wave:state.wave,bonus:bonus});
|
||||
}
|
||||
}
|
||||
function handleCommand(nk,dispatcher,state,message) {
|
||||
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
|
||||
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
|
||||
if (message.opCode===OP_BUILD) changed=handleBuild(state,slot,payload);
|
||||
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
|
||||
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
|
||||
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
|
||||
else if (message.opCode===OP_READY) changed=true;
|
||||
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
|
||||
}
|
||||
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
|
||||
for (var i=0;i<messages.length;i++) handleCommand(nk,dispatcher,state,messages[i]);
|
||||
if (state.started&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); }
|
||||
if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick);
|
||||
return {state:state};
|
||||
}
|
||||
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
|
||||
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
|
||||
|
||||
function InitModule(ctx,logger,nk,initializer) {
|
||||
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
||||
initializer.registerRpc("server_info",rpcServerInfo);
|
||||
initializer.registerRpc("submit_run",rpcSubmitRun);
|
||||
initializer.registerMatchmakerMatched(matchmakerMatched);
|
||||
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
|
||||
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||
const context = { console, Math, Date, JSON, isFinite };
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
|
||||
const sent = [];
|
||||
const dispatcher = {
|
||||
broadcastMessage(opCode, data) {
|
||||
sent.push({ opCode, data: JSON.parse(data) });
|
||||
}
|
||||
};
|
||||
const nk = { binaryToString: data => Buffer.from(data).toString("utf8") };
|
||||
const logger = { info() {} };
|
||||
const presenceA = { userId: "pilot-a", sessionId: "a", username: "Pilot A" };
|
||||
const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" };
|
||||
|
||||
let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state;
|
||||
state = context.matchJoin({}, logger, nk, dispatcher, 1, state, [presenceA]).state;
|
||||
state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state;
|
||||
assert.strictEqual(state.started, true);
|
||||
assert.strictEqual(state.slots["pilot-a"], 0);
|
||||
assert.strictEqual(state.slots["pilot-b"], 1);
|
||||
|
||||
function message(sender, opCode, payload) {
|
||||
return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) };
|
||||
}
|
||||
|
||||
state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [
|
||||
message(presenceA, 10, { tower_type: 0, x: 200, y: 200 }),
|
||||
message(presenceB, 10, { tower_type: 1, x: 2760, y: 200 })
|
||||
]).state;
|
||||
assert.strictEqual(state.towers.length, 2);
|
||||
assert.strictEqual(state.credits[0], 310);
|
||||
assert.strictEqual(state.credits[1], 250);
|
||||
|
||||
state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceA, 13, {})]).state;
|
||||
assert.strictEqual(state.wave, 1);
|
||||
assert.strictEqual(state.waveActive, true);
|
||||
for (let tick = 5; 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));
|
||||
|
||||
const towerA = state.towers.find(tower => tower.owner === 0);
|
||||
state = context.matchLoop({}, logger, nk, dispatcher, 30, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;
|
||||
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
||||
state = context.matchLoop({}, logger, nk, dispatcher, 31, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
||||
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
||||
|
||||
console.log("VOID_BASTION_SERVER_SIM_OK");
|
||||
Reference in new issue
Block a user