multiplayer added
This commit is contained in:
1 parent
df91e0aff8
commit
50718059e0
19 files changed
+1144
-111
No files matched your search
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -8,10 +8,57 @@ A modern [Scorched Earth](https://en.wikipedia.org/wiki/Scorched_Earth_(video_ga
|
||||
|
||||
## How to play
|
||||
|
||||
Tanks take turns to shoot at other tanks. The last surviving tank wins the round. Player 1 (red) is controlled via the keyboard, while other players are randomly-picked AIs.
|
||||
Tanks take turns to shoot at other tanks. The last surviving tank wins the round. Player 1 (red) is controlled via the keyboard, while other players are randomly-picked AIs. You can also play online against friends (see [Multiplayer](#multiplayer)).
|
||||
|
||||
Use the start menu to configure players (3-10), rounds (1-10), and terrain type before each battle. Between rounds, visit the market to spend your score on weapons.
|
||||
|
||||
## Multiplayer
|
||||
|
||||
Play over the internet (or LAN) with up to 6 human players; the remaining slots are filled by AIs. The game uses a **host-authoritative** model: one player's browser runs the simulation, a tiny Node.js relay forwards messages between players, and the other players render from synchronized snapshots.
|
||||
|
||||
### Running the relay
|
||||
|
||||
```sh
|
||||
cd server
|
||||
npm install
|
||||
PORT=8090 npm start
|
||||
```
|
||||
|
||||
### Playing
|
||||
|
||||
1. Open the game and set **Mode → Multiplayer** in the start menu.
|
||||
2. Pick **HOST GAME** to create a room — you'll get a 6-character room code. The host configures total players, rounds, and terrain in the lobby, then presses START GAME.
|
||||
3. Other players pick **JOIN GAME** and enter the code (plus their name).
|
||||
4. Play as usual: aim, fire, and shop in the market. The host's browser simulates the game for everyone; other players can take their turns from anywhere.
|
||||
|
||||
### Hosting options
|
||||
|
||||
- **Same machine as the game** (default): the relay URL is derived from the page URL, so a reverse proxy can forward `/ws` to the relay port.
|
||||
- **Dev/LAN override**: point the game at any relay with `?relay=ws://HOST:PORT` (e.g. `http://localhost:8123/?relay=ws://localhost:8090`).
|
||||
|
||||
Example nginx location for the WebSocket endpoint:
|
||||
|
||||
```nginx
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
### How it works (short)
|
||||
|
||||
- The relay (`server/server.js`) only routes messages: joins, rosters, commands (client → host), and broadcasts (host → clients). It holds no game state.
|
||||
- The host broadcasts a world snapshot on every state change (and every 100–250 ms during projectile/explosion resolution), plus the terrain bitmap as a PNG whenever it changes (craters, collapse, napalm).
|
||||
- Clients render by applying snapshots to the same game code; they simulate nothing, and their aim/fire/market actions are sent as commands to the host.
|
||||
|
||||
### Control flow
|
||||
|
||||
- Turn-based rounds, market economy, and scoring work exactly as in single-player.
|
||||
- If a remote player doesn't fire within 45 seconds of their turn starting, the host auto-fires for them.
|
||||
- If the host leaves, the game ends for everyone.
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Function | With Shift | With Alt |
|
||||
@@ -46,8 +93,8 @@ Use the start menu to configure players (3-10), rounds (1-10), and terrain type
|
||||
- [ ] Many different tanks to pick from
|
||||
- [ ] Many AIs to battle against (only Moron and Chooser implemented)
|
||||
- [ ] Configuration
|
||||
- [ ] Local Multiplayer
|
||||
- [ ] Internet Multiplayer
|
||||
- [X] Local Multiplayer
|
||||
- [X] Internet Multiplayer (host-authoritative, via `server/` relay)
|
||||
|
||||
## What's new
|
||||
|
||||
@@ -58,6 +105,7 @@ This fork adds significant enhancements over the original game:
|
||||
- Multi-round battles with persistent score tracking and weapon purchasing
|
||||
- Improved visual effects: lava explosions, enhanced flame animation, and particle effects
|
||||
- Napalm and Hot napalm weapons
|
||||
- Online multiplayer (up to 6 humans + AI fill) with a Node.js relay server
|
||||
|
||||
## Weapons
|
||||
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="src/main.js?v=15"></script>
|
||||
<script type="module" src="src/main.js?v=20"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ravaged-planet-server",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ravaged-planet-server",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "ravaged-planet-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {WebSocketServer} from 'ws';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {MSG} from '../src/net/protocol.js';
|
||||
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const ROOM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const ROOM_CODE_LENGTH = 6;
|
||||
|
||||
const rooms = new Map();
|
||||
|
||||
function makeRoomCode() {
|
||||
let code;
|
||||
do {
|
||||
code = '';
|
||||
for (let i = 0; i < ROOM_CODE_LENGTH; i++) {
|
||||
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
|
||||
}
|
||||
} while (rooms.has(code));
|
||||
return code;
|
||||
}
|
||||
|
||||
function roomPlayers(room) {
|
||||
return [...room.clients.values()].map(c => ({id: c.id, name: c.name}));
|
||||
}
|
||||
|
||||
function send(ws, msg) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function sendRoster(room) {
|
||||
const roster = {type: MSG.ROSTER, players: roomPlayers(room)};
|
||||
send(room.host.ws, roster);
|
||||
}
|
||||
|
||||
function removeClient(room, client) {
|
||||
room.clients.delete(client.id);
|
||||
if (room.clients.size === 0) {
|
||||
rooms.delete(room.code);
|
||||
console.log(`room ${room.code} closed (empty)`);
|
||||
return;
|
||||
}
|
||||
if (client.host) {
|
||||
for (const c of room.clients.values()) send(c.ws, {type: MSG.HOST_LEFT});
|
||||
rooms.delete(room.code);
|
||||
console.log(`room ${room.code} closed (host left)`);
|
||||
} else {
|
||||
sendRoster(room);
|
||||
console.log(`${client.name} (${client.id}) left room ${room.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({port: PORT});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const ip = req.socket.remoteAddress;
|
||||
let client = null;
|
||||
let room = null;
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
send(ws, {type: MSG.ERROR, message: 'bad message'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === MSG.JOIN) {
|
||||
if (client) {
|
||||
send(ws, {type: MSG.ERROR, message: 'already in a room'});
|
||||
return;
|
||||
}
|
||||
const code = String(msg.room || '').toUpperCase().trim();
|
||||
if (!new RegExp(`^[${ROOM_CODE_ALPHABET}]{${ROOM_CODE_LENGTH}}$`).test(code)) {
|
||||
send(ws, {type: MSG.ERROR, message: 'invalid room code'});
|
||||
return;
|
||||
}
|
||||
const name = String(msg.name || 'Player').slice(0, 20);
|
||||
const id = randomUUID();
|
||||
|
||||
room = rooms.get(code);
|
||||
if (!room) {
|
||||
room = {code, host: null, clients: new Map()};
|
||||
rooms.set(code, room);
|
||||
}
|
||||
|
||||
const isHost = room.clients.size === 0;
|
||||
client = {id, name, ws, host: isHost};
|
||||
room.clients.set(id, client);
|
||||
if (isHost) room.host = client;
|
||||
|
||||
send(ws, {type: MSG.JOINED, id, host: isHost, room: code});
|
||||
if (isHost) {
|
||||
console.log(`room ${code} created by ${name} (${ip})`);
|
||||
sendRoster(room);
|
||||
} else {
|
||||
console.log(`${name} (${ip}) joined room ${code}`);
|
||||
sendRoster(room);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === MSG.LEAVE) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client || !room) {
|
||||
send(ws, {type: MSG.ERROR, message: 'not in a room'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.host) {
|
||||
if (msg.type === MSG.BROADCAST) {
|
||||
for (const c of room.clients.values()) {
|
||||
if (!c.host) send(c.ws, msg.payload);
|
||||
}
|
||||
} else {
|
||||
send(ws, {type: MSG.ERROR, message: `unknown host message type: ${msg.type}`});
|
||||
}
|
||||
} else {
|
||||
if (msg.type === MSG.COMMAND) {
|
||||
send(room.host.ws, msg);
|
||||
} else {
|
||||
send(ws, {type: MSG.ERROR, message: `unknown client message type: ${msg.type}`});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (client && room) removeClient(room, client);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`client ${ip} error:`, err.message);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`ravaged-planet relay listening on :${PORT}`);
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PLAYER_MAX_ENERGY, PLAYER_INITIAL_POWER, PLAYER_ENERGY_POWER_MULTIPLIER} from './constants.js?v=15';
|
||||
import {randomInt} from './math.js?v=15';
|
||||
import {PLAYER_MAX_ENERGY, PLAYER_INITIAL_POWER, PLAYER_ENERGY_POWER_MULTIPLIER} from './constants.js?v=20';
|
||||
import {randomInt} from './math.js?v=20';
|
||||
|
||||
export const AI_TYPES = {
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import {splitWords} from './utils.js?v=15';
|
||||
import {parable} from './math.js?v=15';
|
||||
import {FONT_HEIGHT, FONT_WIDTH} from './constants.js?v=15';
|
||||
import {splitWords} from './utils.js?v=20';
|
||||
import {parable} from './math.js?v=20';
|
||||
import {FONT_HEIGHT, FONT_WIDTH} from './constants.js?v=20';
|
||||
|
||||
export function createCanvas(width, height, readable) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import {DEFAULT_KEYPRESS_DELAY, Z} from './constants.js?v=15';
|
||||
import {DEFAULT_KEYPRESS_DELAY, Z} from './constants.js?v=20';
|
||||
|
||||
const input = {};
|
||||
let lastKeypressTime = 0;
|
||||
|
||||
+699
-78
File diff suppressed because it is too large.
Load diff
+136
@@ -0,0 +1,136 @@
|
||||
import {MSG, HOST_MSG} from './net/protocol.js?v=20';
|
||||
|
||||
let socket = null;
|
||||
let myId = null;
|
||||
let isHost = false;
|
||||
let room = null;
|
||||
let myName = '';
|
||||
let handlers = [];
|
||||
let statusHandlers = [];
|
||||
let status = 'disconnected';
|
||||
|
||||
function relayUrl() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const override = params.get('relay');
|
||||
if (override) return override;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}/ws`;
|
||||
}
|
||||
|
||||
export function netStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
export function netIsConnected() {
|
||||
return status === 'connected';
|
||||
}
|
||||
|
||||
export function netIsHost() {
|
||||
return isHost;
|
||||
}
|
||||
|
||||
export function netRoom() {
|
||||
return room;
|
||||
}
|
||||
|
||||
export function netMyId() {
|
||||
return myId;
|
||||
}
|
||||
|
||||
export function netMakeRoomCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
||||
return code;
|
||||
}
|
||||
|
||||
export function netOnMessage(fn) {
|
||||
handlers.push(fn);
|
||||
}
|
||||
|
||||
export function netOnStatus(fn) {
|
||||
statusHandlers.push(fn);
|
||||
}
|
||||
|
||||
function emit(message) {
|
||||
for (const fn of handlers) fn(message);
|
||||
}
|
||||
|
||||
function setStatus(value) {
|
||||
status = value;
|
||||
for (const fn of statusHandlers) fn(value);
|
||||
}
|
||||
|
||||
export function netConnect(name, code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket) {
|
||||
try { socket.close(); } catch {}
|
||||
socket = null;
|
||||
}
|
||||
myName = name;
|
||||
setStatus('connecting');
|
||||
let joined = false;
|
||||
socket = new WebSocket(relayUrl());
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({type: MSG.JOIN, room: code, name}));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === MSG.JOINED) {
|
||||
joined = true;
|
||||
myId = msg.id;
|
||||
isHost = msg.host;
|
||||
room = msg.room;
|
||||
setStatus('connected');
|
||||
resolve({id: msg.id, host: msg.host, room: msg.room});
|
||||
return;
|
||||
}
|
||||
emit(msg);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setStatus('disconnected');
|
||||
if (!joined) reject(new Error('connection failed'));
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setStatus('disconnected');
|
||||
socket = null;
|
||||
myId = null;
|
||||
isHost = false;
|
||||
room = null;
|
||||
if (!joined) reject(new Error('connection closed'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function netSendCommand(cmd) {
|
||||
if (!socket || status !== 'connected') return;
|
||||
socket.send(JSON.stringify({type: MSG.COMMAND, playerId: myId, cmd}));
|
||||
}
|
||||
|
||||
export function netBroadcast(payload) {
|
||||
if (!socket || status !== 'connected' || !isHost) return;
|
||||
socket.send(JSON.stringify({type: MSG.BROADCAST, payload}));
|
||||
}
|
||||
|
||||
export function netDisconnect() {
|
||||
if (socket) {
|
||||
try {
|
||||
socket.send(JSON.stringify({type: MSG.LEAVE}));
|
||||
} catch {}
|
||||
try { socket.close(); } catch {}
|
||||
}
|
||||
socket = null;
|
||||
myId = null;
|
||||
isHost = false;
|
||||
room = null;
|
||||
setStatus('disconnected');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Wire protocol shared between the browser client, the host and the relay.
|
||||
// All messages are JSON objects with a `type` field.
|
||||
|
||||
export const MSG = {
|
||||
// client -> relay
|
||||
JOIN: 'join', // {room, name}
|
||||
LEAVE: 'leave', // {}
|
||||
// relay -> client
|
||||
JOINED: 'joined', // {id, host, room}
|
||||
ROSTER: 'roster', // {players: [{id, name}]} (host only)
|
||||
HOST_LEFT: 'host-left', // {}
|
||||
ERROR: 'error', // {message}
|
||||
// client -> relay -> host (non-host clients only)
|
||||
COMMAND: 'command', // {playerId, cmd}
|
||||
// host -> relay -> other clients (verbatim payload)
|
||||
BROADCAST: 'broadcast', // {payload}
|
||||
};
|
||||
|
||||
export const CMD = {
|
||||
READY: 'ready', // lobby: client is ready
|
||||
UNREADY: 'unready', // lobby: client no longer ready
|
||||
CONFIG: 'config', // lobby: host updates lobby config {players, rounds, terrain}
|
||||
START: 'start', // lobby: host starts the game
|
||||
FIRE: 'fire', // {a, p, weaponIndex}
|
||||
BUY: 'buy', // {item}
|
||||
SELL: 'sell', // {item}
|
||||
CHAT: 'chat', // {text}
|
||||
};
|
||||
|
||||
export const HOST_MSG = {
|
||||
LOBBY: 'lobby', // {players: [{id, name, ready}], config: {players, rounds, terrain}}
|
||||
WORLD: 'world', // {snap}
|
||||
TERRAIN: 'terrain', // {png: base64}
|
||||
EVENT: 'event', // {kind, ...}
|
||||
START_ROUND: 'start-round', // {} round N begins
|
||||
END_GAME: 'end-game', // {reason}
|
||||
};
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import {PANEL_WIDTH, PANEL_HEIGHT, PANEL_FONT_SIZE, PANEL_TITLE_FONT_SIZE, PANEL_BG_COLOR, PANEL_BORDER_COLOR, W, H, FONT_WIDTH} from './constants.js?v=15';
|
||||
import {drawRect, drawLine} from './gfx.js?v=15';
|
||||
import {PANEL_WIDTH, PANEL_HEIGHT, PANEL_FONT_SIZE, PANEL_TITLE_FONT_SIZE, PANEL_BG_COLOR, PANEL_BORDER_COLOR, W, H, FONT_WIDTH} from './constants.js?v=20';
|
||||
import {drawRect, drawLine} from './gfx.js?v=20';
|
||||
|
||||
export const PANEL_X = (W - PANEL_WIDTH) / 2;
|
||||
export {PANEL_WIDTH};
|
||||
const PANEL_Y = (H - PANEL_HEIGHT) / 2;
|
||||
|
||||
export function drawButton(ctx, x, y, w, h, label, active) {
|
||||
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
import {H, PROJECTILE_ITERATIONS_PER_FRAME, PROJECTILE_ITERATION_PROGRESS, PROJECTILE_MAX_SOUND_FREQUENCY, PROJECTILE_MIN_SOUND_FREQUENCY, PROJECTILE_POWER_REDUCTION_FACTOR, PROJECTILE_WIND_REDUCTION_FACTOR, WEAPON_TYPES, PLAYER_TANK_Y_FOOTPRINT} from './constants.js?v=15';
|
||||
import {checkLineWith, drawLineVirtual} from './gfx.js?v=15';
|
||||
import {createParticles, isTank, isTankShield, spawnNapalm, tracerMode} from './main.js?v=15';
|
||||
import {deg2rad, parable, rad2deg, wrap} from './math.js?v=15';
|
||||
import {audio, createOsc} from './sound.js?v=15';
|
||||
import {isTerrain, landHeight} from './terrain.js?v=15';
|
||||
import {EXPLOSION_TYPES} from './weapons.js?v=15';
|
||||
import {H, PROJECTILE_ITERATIONS_PER_FRAME, PROJECTILE_ITERATION_PROGRESS, PROJECTILE_MAX_SOUND_FREQUENCY, PROJECTILE_MIN_SOUND_FREQUENCY, PROJECTILE_POWER_REDUCTION_FACTOR, PROJECTILE_WIND_REDUCTION_FACTOR, WEAPON_TYPES, PLAYER_TANK_Y_FOOTPRINT} from './constants.js?v=20';
|
||||
import {checkLineWith, drawLineVirtual} from './gfx.js?v=20';
|
||||
import {createParticles, isTank, isTankShield, spawnNapalm, tracerMode} from './main.js?v=20';
|
||||
import {deg2rad, parable, rad2deg, wrap} from './math.js?v=20';
|
||||
import {audio, createOsc} from './sound.js?v=20';
|
||||
import {isTerrain, landHeight} from './terrain.js?v=20';
|
||||
import {EXPLOSION_TYPES} from './weapons.js?v=20';
|
||||
|
||||
|
||||
export const PROJECTILE_TYPES = {
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import {SKY_COLORS} from './constants.js?v=15';
|
||||
import {drawRect} from './gfx.js?v=15';
|
||||
import {gradient} from './math.js?v=15';
|
||||
import {sample} from './utils.js?v=15';
|
||||
import {SKY_COLORS} from './constants.js?v=20';
|
||||
import {drawRect} from './gfx.js?v=20';
|
||||
import {gradient} from './math.js?v=20';
|
||||
import {sample} from './utils.js?v=20';
|
||||
|
||||
export function generateSky(ctx) {
|
||||
const {width, height} = ctx.canvas;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import {SOUND_MUSIC_VOLUME, SOUND_SFX_VOLUME} from './constants.js?v=15';
|
||||
import {SOUND_MUSIC_VOLUME, SOUND_SFX_VOLUME} from './constants.js?v=20';
|
||||
|
||||
// @ts-ignore
|
||||
export const audio = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import {clipCanvas, drawCircle, drawRect} from './gfx.js?v=15';
|
||||
import {clamp, coords2index, random, randomInt} from './math.js?v=15';
|
||||
import {sample} from './utils.js?v=15';
|
||||
import {clipCanvas, drawCircle, drawRect} from './gfx.js?v=20';
|
||||
import {clamp, coords2index, random, randomInt} from './math.js?v=20';
|
||||
import {sample} from './utils.js?v=20';
|
||||
|
||||
let cachedImageData;
|
||||
|
||||
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
import {randomInt} from './math.js?v=15';
|
||||
import {randomInt} from './math.js?v=20';
|
||||
|
||||
export function sample(arr) {
|
||||
return arr[randomInt(0, arr.length-1)];
|
||||
@@ -29,6 +29,11 @@ export function nextId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function newPlayerId() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
|
||||
return `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function splitWords(text) {
|
||||
let words = [''];
|
||||
for (let char of text) {
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
import {PLAYER_TANK_BOUNDING_RADIUS, PLAYER_TANK_Y_FOOTPRINT, SHIELD_TYPES} from './constants.js?v=15';
|
||||
import {drawCircle, plot} from './gfx.js?v=15';
|
||||
import {clamp, cycle, distance, randomInt} from './math.js?v=15';
|
||||
import {audio, createOsc} from './sound.js?v=15';
|
||||
import {clipTerrain} from './terrain.js?v=15';
|
||||
import {PLAYER_TANK_BOUNDING_RADIUS, PLAYER_TANK_Y_FOOTPRINT, SHIELD_TYPES} from './constants.js?v=20';
|
||||
import {drawCircle, plot} from './gfx.js?v=20';
|
||||
import {clamp, cycle, distance, randomInt} from './math.js?v=20';
|
||||
import {audio, createOsc} from './sound.js?v=20';
|
||||
import {clipTerrain} from './terrain.js?v=20';
|
||||
|
||||
export function drawExplosion(ctx, x, y, r) {
|
||||
const step = Math.max(2, Math.floor(r / 6));
|
||||
|
||||
Reference in new issue
Block a user