Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42dfd9eb83
|
||
|
|
ba2199c60b | ||
|
|
c3c6ba2f33 | ||
|
|
1ddd5c781d | ||
|
|
37a6581a27 | ||
|
|
c1a093cb45 | ||
|
|
b3e8bc4889 | ||
|
|
f4842a9f32 | ||
|
|
5d13cb900e | ||
|
|
c66cc42118 | ||
|
|
cc6a474a88 |
@@ -0,0 +1,27 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: reg_user
|
||||||
|
password:
|
||||||
|
from_secret: reg_password
|
||||||
|
registry: dockerreg.gltronic.ovh
|
||||||
|
repo: dockerreg.gltronic.ovh/tronio
|
||||||
|
- name: deploy
|
||||||
|
image: appleboy/drone-ssh
|
||||||
|
settings:
|
||||||
|
host:
|
||||||
|
from_secret: deploy_host
|
||||||
|
username:
|
||||||
|
from_secret: deploy_user
|
||||||
|
password:
|
||||||
|
from_secret: deploy_password
|
||||||
|
port: 22
|
||||||
|
script:
|
||||||
|
- cd docker/perso
|
||||||
|
- docker-compose pull tronio
|
||||||
|
- docker-compose up -d tronio
|
||||||
@@ -21,4 +21,5 @@ pnpm-debug.log*
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
tronio.jar
|
server/dist
|
||||||
|
client/dist
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
# https://medium.com/bb-tutorials-and-thoughts/packaging-your-vue-js-app-with-nodejs-backend-for-production-83abe213532c
|
FROM node:latest AS client-build
|
||||||
|
|
||||||
FROM node:10 AS ui-build
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY client/ ./client/
|
COPY client/ ./client/
|
||||||
RUN cd client && npm install && npm run build
|
RUN cd client && npm install && npm run build
|
||||||
|
|
||||||
FROM node:10 AS server-build
|
FROM node:latest AS server-build
|
||||||
WORKDIR /root/
|
WORKDIR /usr/src/app
|
||||||
COPY --from=ui-build /usr/src/app/client/dist ./server/dist
|
COPY server/ ./server/
|
||||||
COPY server/package*.json ./server/
|
RUN cd server && npm install && npm run build
|
||||||
RUN cd server && npm install
|
|
||||||
COPY server/src ./server/src
|
|
||||||
|
|
||||||
EXPOSE 8080
|
FROM node:latest AS serve
|
||||||
|
WORKDIR /usr/src/app
|
||||||
CMD ["node", "./server/src/server.js"]
|
COPY server/package*.json ./
|
||||||
|
RUN npm install --only=production
|
||||||
|
COPY --from=server-build /usr/src/app/server/dist ./distServer
|
||||||
|
COPY --from=client-build /usr/src/app/client/dist ./distClient
|
||||||
|
CMD ["node", "./distServer/app.js"]
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Tron.io Rewrite — Plain JS Client + Rust Server
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Server:** Rust, `actix-web` (HTTP + WebSocket), `serde_json` for the wire protocol, `uuid` for player IDs, `rand` for colors/spawn.
|
||||||
|
- **Client:** plain HTML/CSS + native ES modules (`<script type="module">`). No bundler, no framework, no PWA.
|
||||||
|
- **Protocol:** JSON, same shape as today. Server-authoritative at 60 Hz, broadcasts every other tick. Client interpolates between the last two snapshots.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── Cargo.toml
|
||||||
|
└── src/
|
||||||
|
├── main.rs # actix App: GET /socket -> ws, serve ./client via actix-files
|
||||||
|
├── settings.rs # GameSettings (playerSize=10, playerSpeed=5, wallUpdate=3, arenaSize=1000, wallSize=8)
|
||||||
|
├── wall.rs # Wall { x1,y1,x2,y2, bbox }
|
||||||
|
├── player.rs # Player state + step() + collision helpers
|
||||||
|
├── spatial.rs # SpatialHash over wall segments
|
||||||
|
├── collision.rs # segment-vs-circle check
|
||||||
|
├── game.rs # tick loop, broadcast, delta updates
|
||||||
|
├── protocol.rs # serde enums: ClientMsg, ServerMsg
|
||||||
|
└── session.rs # actix WS session actor
|
||||||
|
|
||||||
|
client/
|
||||||
|
├── index.html # menu + canvas
|
||||||
|
├── style.css
|
||||||
|
├── assets/ # reuse sprite/, sound/, logo, TRON font
|
||||||
|
└── src/
|
||||||
|
├── main.js # entry, wire DOM
|
||||||
|
├── socket.js # WebSocket wrapper
|
||||||
|
├── state.js # plain module (no Vuex)
|
||||||
|
├── interpolate.js # snapshot interpolation
|
||||||
|
├── game.js # rAF loop
|
||||||
|
├── render.js # canvas 2D drawing
|
||||||
|
├── trail_canvas.js # offscreen persistent trail layer
|
||||||
|
├── sprites.js # Explosion
|
||||||
|
└── sound.js # Audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message protocol (JSON)
|
||||||
|
|
||||||
|
Client → server:
|
||||||
|
```json
|
||||||
|
{ "type": "login", "name": "..." }
|
||||||
|
{ "type": "logout" }
|
||||||
|
{ "type": "respawn" }
|
||||||
|
{ "type": "update", "player": { "angle": 1.23 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Server → client:
|
||||||
|
```json
|
||||||
|
{ "type": "login", "player": { ...full... } }
|
||||||
|
{ "type": "gameSettings", "gameSettings": { ... } }
|
||||||
|
{ "type": "gamePlayerSpawn", "player": { ...full incl. all walls... } }
|
||||||
|
{ "type": "gamePlayerDead", "player": { ... } }
|
||||||
|
{ "type": "gameUpdate", "time": <ms>, "players": [
|
||||||
|
{ "id","name","color","x","y","angle","state","score",
|
||||||
|
"newSegments": [x1,y1,x2,y2, x1,y1,x2,y2, ...] }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`gamePlayerSpawn` carries the full wall list (for joins/respawns). `gameUpdate` carries only the segments added since the previous update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The hard algorithms
|
||||||
|
|
||||||
|
These are the parts worth understanding before you write them. Everything else is straightforward porting.
|
||||||
|
|
||||||
|
### 1. Wall as segment + collinear collapse
|
||||||
|
|
||||||
|
**Why:** Tron trails are mostly straight lines with occasional turns. Storing one point per tick (current code) means thousands of redundant points per straight run. Storing segments collapses that to one segment per straight run — 10–100× less data, smaller JSON, faster collision, faster render.
|
||||||
|
|
||||||
|
**Data model:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Wall {
|
||||||
|
x1: f32, y1: f32, // start
|
||||||
|
x2: f32, y2: f32, // end (mutable — extended each tick while straight)
|
||||||
|
// bbox cached:
|
||||||
|
min_x: f32, min_y: f32, max_x: f32, max_y: f32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The collapse rule** (in `Player::step`):
|
||||||
|
|
||||||
|
- Keep the *current (open) segment* — the one being extended.
|
||||||
|
- Each tick, advance the player by `playerSpeed * (cos angle, sin angle)`.
|
||||||
|
- Compare the new heading to the open segment's heading:
|
||||||
|
- If **parallel** (within a small epsilon, e.g. `1e-4` radians): just update `x2,y2` of the open segment. No new wall added.
|
||||||
|
- If **different** (player turned): finalize the open segment (recompute its bbox), push a new open segment starting at the old end with the new heading.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn step(&mut self, ticks: u32) {
|
||||||
|
for _ in 0..ticks {
|
||||||
|
self.last_wall += 1;
|
||||||
|
let nx = self.x + speed * cos(self.angle);
|
||||||
|
let ny = self.y + speed * sin(self.angle);
|
||||||
|
|
||||||
|
match self.open_segment {
|
||||||
|
Some(seg) => {
|
||||||
|
let seg_angle = atan2(seg.y2 - seg.y1, seg.x2 - seg.x1);
|
||||||
|
let d = (self.angle - seg_angle).abs().rem_euclid(2*PI);
|
||||||
|
let d = d.min(2*PI - d); // smallest angular difference
|
||||||
|
|
||||||
|
if d < 1e-4 {
|
||||||
|
// extend the open segment
|
||||||
|
seg.x2 = nx; seg.y2 = ny;
|
||||||
|
seg.max_x = seg.max_x.max(nx); seg.max_y = seg.max_y.max(ny);
|
||||||
|
seg.min_x = seg.min_x.min(nx); seg.min_y = seg.min_y.min(ny);
|
||||||
|
} else {
|
||||||
|
// finalize open segment, push new one
|
||||||
|
self.walls.push(seg); // (or keep open as last element — your choice)
|
||||||
|
self.open_segment = Some(Wall::new(self.x, self.y, nx, ny));
|
||||||
|
self.score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.open_segment = Some(Wall::new(self.x, self.y, nx, ny)),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.x = nx; self.y = ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Design choice to make:** either keep the open segment as the last element of `self.walls` (simpler iteration for collision), or as a separate field (cleaner). I'd keep it separate and only push to `walls` on finalize — it makes "don't collide with your own current segment" trivial.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Use `f32` consistently; `f64` everywhere is overkill and slower.
|
||||||
|
- Don't finalize on every tick — only when heading actually changes. That's the whole point.
|
||||||
|
- Reset (respawn) must clear both `walls` *and* `open_segment`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Spatial hash for collision
|
||||||
|
|
||||||
|
**Why:** Current code is `O(players² × walls)` per tick — every player checks every wall of every other player. With 1000 walls and 10 players that's 100k checks per tick. A spatial hash reduces it to `O(players × k)` where `k` is the number of segments in nearby cells (usually <10).
|
||||||
|
|
||||||
|
**Grid:**
|
||||||
|
- Cell size = `playerSize` (or `2 * playerSize`). Smaller cells = more buckets but fewer entries per bucket; pick something close to the player's collision radius.
|
||||||
|
- Grid dimensions: `arenaSize / cell_size` cells per axis.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct SpatialHash {
|
||||||
|
cell_size: f32,
|
||||||
|
cols: i32,
|
||||||
|
rows: i32,
|
||||||
|
buckets: HashMap<(i32, i32), Vec<SegmentId>>, // or Vec<Vec<...>> if arena is fixed-size
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inserting a segment:** rasterize the segment's bbox into the grid — for every cell the bbox overlaps, add the segment ID to that bucket.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn insert(&mut self, seg: &Wall, id: SegmentId) {
|
||||||
|
let (cx1, cy1) = self.cell_of(seg.min_x, seg.min_y);
|
||||||
|
let (cx2, cy2) = self.cell_of(seg.max_x, seg.max_y);
|
||||||
|
for cx in cx1..=cx2 {
|
||||||
|
for cy in cy1..=cy2 {
|
||||||
|
self.buckets.entry((cx, cy)).or_default().push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Querying near a player:** compute the player's cell range (player bbox → cells) and union the buckets.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn query(&self, px: f32, py: f32, radius: f32) -> HashSet<SegmentId> {
|
||||||
|
let (cx1, cy1) = self.cell_of(px - radius, py - radius);
|
||||||
|
let (cx2, cy2) = self.cell_of(px + radius, py + radius);
|
||||||
|
let mut out = HashSet::new();
|
||||||
|
for cx in cx1..=cx2 {
|
||||||
|
for cy in cy1..=cy2 {
|
||||||
|
if let Some(b) = self.buckets.get(&(cx, cy)) {
|
||||||
|
out.extend(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per-tick maintenance:**
|
||||||
|
- New finalized segments → `insert`.
|
||||||
|
- On respawn → remove that player's segments from all buckets they were in. Keep a reverse map `player_id -> Vec<SegmentId>` and a `segments: HashMap<SegmentId, Wall>` so removal is cheap.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Don't reinsert the open segment every tick — it changes every tick. Either skip it (don't collide with own current segment anyway) or only reinsert when it's finalized.
|
||||||
|
- Use a `HashSet` for query results to dedupe segments that span multiple cells.
|
||||||
|
- For a 1000×1000 arena with cell_size 10, that's 10k cells — fine as a `HashMap`. If arena grows, switch to a flat `Vec` indexed by `cy * cols + cx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Segment-vs-circle collision
|
||||||
|
|
||||||
|
**Why:** This is the actual collision test once spatial hash narrows the candidates. Player is a circle (radius = `playerSize`); wall is a line segment.
|
||||||
|
|
||||||
|
**Algorithm:** distance from point P to segment AB. If less than radius, collision.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn point_segment_distance(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
|
||||||
|
let abx = bx - ax;
|
||||||
|
let aby = by - ay;
|
||||||
|
let apx = px - ax;
|
||||||
|
let apy = py - ay;
|
||||||
|
let ab_len_sq = abx*abx + aby*aby;
|
||||||
|
|
||||||
|
// t in [0,1] is the projection of P onto line AB
|
||||||
|
let t = if ab_len_sq > 0.0 {
|
||||||
|
((apx * abx + apy * aby) / ab_len_sq).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0 // degenerate segment (A == B)
|
||||||
|
};
|
||||||
|
|
||||||
|
let closest_x = ax + t * abx;
|
||||||
|
let closest_y = ay + t * aby;
|
||||||
|
let dx = px - closest_x;
|
||||||
|
let dy = py - closest_y;
|
||||||
|
(dx*dx + dy*dy).sqrt()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: `if distance < playerSize { collision }`.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Always clamp `t` to `[0,1]` — that's what makes it a *segment* test instead of an *infinite line* test. Forgetting this is a classic bug (player collides with the imaginary extension of a wall).
|
||||||
|
- Avoid `sqrt` until the end: compare squared distance to squared radius, then sqrt only if you need the actual distance. Minor optimization.
|
||||||
|
- Don't test the open segment of the *same* player — they're riding it, of course they're touching it. (You do want to test the previous finalized segment though, to prevent 180° turns into themselves.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Delta wall updates + resync
|
||||||
|
|
||||||
|
**Why:** The current server re-serializes the *entire* wall list of every player on every broadcast. After 2 minutes of play with 5 players that's tens of thousands of points every 33ms — that's the main source of slowdown.
|
||||||
|
|
||||||
|
**Server side (in `game.rs` broadcast):**
|
||||||
|
- Each `Player` keeps a `pending_segments: Vec<Wall>` — segments finalized since the last broadcast.
|
||||||
|
- On broadcast, send each player's `pending_segments` (serialized as a flat `[x1,y1,x2,y2, ...]` array) and clear it.
|
||||||
|
- The currently-open segment is *not* sent (it's still changing). Client renders it locally as a line from its start to the player's interpolated position.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// build the update message
|
||||||
|
for player in players.values() {
|
||||||
|
let new_segments: Vec<f32> = player.pending_segments.iter()
|
||||||
|
.flat_map(|w| [w.x1, w.y1, w.x2, w.y2])
|
||||||
|
.collect();
|
||||||
|
player.pending_segments.clear();
|
||||||
|
// ... pack into update message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client side (in `trail_canvas.js`):**
|
||||||
|
- Maintain `Map<playerId, { offscreenCanvas, lastSegmentEnd }>` per player.
|
||||||
|
- On `gameUpdate`: for each player, stroke each new segment onto their offscreen canvas, then update `lastSegmentEnd` to the last segment's end.
|
||||||
|
- On `gamePlayerSpawn` (join/respawn): clear that player's offscreen canvas, then stroke all walls from the full snapshot.
|
||||||
|
|
||||||
|
**Resync:** late joiners and respawns need the full wall list. Send it in `gamePlayerSpawn`, never in `gameUpdate`. If you ever detect a desync (e.g. client missed an update), the recovery is to request a full snapshot — but with reliable WS messages you generally don't need this.
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- The open segment (currently being extended) is rendered client-side as a line from the last finalized point to the player's interpolated position. Don't try to send it incrementally.
|
||||||
|
- When a player dies, their walls stay on the field (so others can still crash into them) but no new segments are added. When they respawn, the old walls are removed from the spatial hash and from the client's offscreen canvas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Offscreen persistent trail canvas
|
||||||
|
|
||||||
|
**Why:** Redrawing the entire polyline every frame is `O(N)` where N grows over the match. With an offscreen canvas, you stroke each segment *once* when it arrives, and per-frame you just blit the canvas — `O(1)` regardless of trail length.
|
||||||
|
|
||||||
|
**Setup (in `trail_canvas.js`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// one offscreen canvas per player, sized to the arena
|
||||||
|
const trailCanvases = new Map(); // playerId -> { canvas, ctx }
|
||||||
|
|
||||||
|
function getPlayerTrail(playerId) {
|
||||||
|
let t = trailCanvases.get(playerId);
|
||||||
|
if (!t) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = ARENA_SIZE;
|
||||||
|
canvas.height = ARENA_SIZE;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.lineWidth = WALL_SIZE;
|
||||||
|
ctx.lineCap = 'square';
|
||||||
|
t = { canvas, ctx };
|
||||||
|
trailCanvases.set(playerId, t);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adding segments:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function addSegments(playerId, color, segments) {
|
||||||
|
const { ctx } = getPlayerTrail(playerId);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < segments.length; i += 4) {
|
||||||
|
ctx.moveTo(segments[i], segments[i+1]);
|
||||||
|
ctx.lineTo(segments[i+2], segments[i+3]);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rendering each frame:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function render(mainCtx, camera, canvasW, canvasH) {
|
||||||
|
for (const [playerId, t] of trailCanvases) {
|
||||||
|
const sx = canvasW/2 - camera.x;
|
||||||
|
const sy = canvasH/2 - camera.y;
|
||||||
|
mainCtx.drawImage(t.canvas, sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The open segment:** draw separately on the main canvas each frame — a line from the last finalized point of each player to their interpolated (x,y).
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- Coordinate system: trail canvas is in *world* coordinates (origin = arena top-left). Blit with `drawImage(canvas, canvasW/2 - camera.x, canvasH/2 - camera.y)` to get screen coords.
|
||||||
|
- On respawn: `ctx.clearRect(0, 0, ARENA_SIZE, ARENA_SIZE)` for that player's canvas.
|
||||||
|
- Set `imageSmoothingEnabled = false` on the main context to avoid blurry trails.
|
||||||
|
- Trail canvas size = arena size. With `arenaSize=1000` that's 1MB per player (4 bytes × 1000² × alpha). Fine for ~50 players. If you scale up, switch to a single shared trail canvas and stroke per-player in their color.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Client interpolation between snapshots
|
||||||
|
|
||||||
|
**Why:** The server runs at 60 Hz but network packets jitter. Rendering the latest snapshot directly causes stutter. Buffering two snapshots and interpolating between them by a fixed delay (~100ms behind real time) smooths everything out.
|
||||||
|
|
||||||
|
**The two-snapshot buffer:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// state.js
|
||||||
|
let snapshots = []; // [{ time, players: Map<id, playerData> }, ...]
|
||||||
|
|
||||||
|
function pushSnapshot(time, players) {
|
||||||
|
snapshots.push({ time, players });
|
||||||
|
// keep at most the last 2-3 snapshots
|
||||||
|
while (snapshots.length > 3) snapshots.shift();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interpolation target:** render time = `now - 100ms` (the 100ms is your "interpolation delay"; tune it).
|
||||||
|
|
||||||
|
**Algorithm (in `interpolate.js`):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
function interpolate(snapshots, renderTime) {
|
||||||
|
if (snapshots.length === 0) return [];
|
||||||
|
if (snapshots.length === 1) return [...snapshots[0].players.values()];
|
||||||
|
|
||||||
|
// find the two snapshots that bracket renderTime
|
||||||
|
let a = snapshots[0], b = snapshots[1];
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
if (snapshots[i].time > renderTime) {
|
||||||
|
a = snapshots[i-1];
|
||||||
|
b = snapshots[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// if renderTime is after the last snapshot, extrapolate from the last two
|
||||||
|
a = snapshots[snapshots.length - 2];
|
||||||
|
b = snapshots[snapshots.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = b.time - a.time;
|
||||||
|
const t = span > 0 ? (renderTime - a.time) / span : 1.0;
|
||||||
|
const tClamped = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const playerA of a.players.values()) {
|
||||||
|
const playerB = b.players.get(playerA.id);
|
||||||
|
if (!playerB) { out.push(playerA); continue; }
|
||||||
|
out.push({
|
||||||
|
...playerA,
|
||||||
|
x: lerp(playerA.x, playerB.x, tClamped),
|
||||||
|
y: lerp(playerA.y, playerB.y, tClamped),
|
||||||
|
angle: lerpAngle(playerA.angle, playerB.angle, tClamped),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
function lerpAngle(a, b, t) {
|
||||||
|
let d = b - a;
|
||||||
|
while (d > Math.PI) d -= 2 * Math.PI;
|
||||||
|
while (d < -Math.PI) d += 2 * Math.PI;
|
||||||
|
return a + d * t;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotchas:**
|
||||||
|
- **Angle interpolation**: never lerp angles directly — `lerp(350°, 10°, 0.5)` = 180°, which is wrong. Always take the shortest path around the circle (the `while` loops above). This was a bug in the original `update.js`.
|
||||||
|
- **Walls are not interpolated**: they're static once placed. Only interpolate `x`, `y`, `angle`.
|
||||||
|
- **Clock sync**: the server sends `time` in ms. The client can't compare `Date.now()` to it directly because clocks differ. On the first `gameUpdate`, record `localTimeAtFirstUpdate = Date.now()` and `serverTimeAtFirstUpdate = update.time`. From then on, `serverNow = update.time + (Date.now() - localTimeAtFirstUpdate)`. Use that to compute render time.
|
||||||
|
- If the client falls behind (e.g. tab was backgrounded), snapshots may be too old — fall back to rendering the latest snapshot directly until the buffer refills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested implementation order
|
||||||
|
|
||||||
|
These are ordered so each step produces a runnable, testable state.
|
||||||
|
|
||||||
|
1. **Echo server** — actix-web with `/socket` route, accepts WS, echoes messages. Verify with browser devtools.
|
||||||
|
2. **Static client serving** — actix-files serves `client/index.html` at `/`. Verify you see the page.
|
||||||
|
3. **Settings + protocol + login** — `settings.rs`, `protocol.rs`, handle `login` message, send back `login` + `gameSettings`. Verify with a minimal client that just logs and console.logs the response.
|
||||||
|
4. **Player + tick loop + broadcast** — `player.rs` (point-based, no segments yet), `game.rs` 60 Hz tick, broadcast full state every other tick. Get a single player moving on screen.
|
||||||
|
5. **Mouse input + rendering** — port `render.js` and mouse handling. You should now have a single dot you can steer around the arena.
|
||||||
|
6. **Collision (brute force)** — port the current O(P²×W) check just to get death working.
|
||||||
|
7. **Switch walls to segments + collinear collapse** — this is the big one. Once done, trails become straight lines with corners. Verify trails look right.
|
||||||
|
8. **Spatial hash** — replace brute-force collision. Verify collisions still correct with many walls.
|
||||||
|
9. **Delta updates** — switch from full-state broadcast to `newSegments`. Client accumulates. Verify long matches don't slow down.
|
||||||
|
10. **Offscreen trail canvas** — switch client render from "rebuild polyline every frame" to "stroke on arrival, blit per frame". Verify render is now O(1) per frame.
|
||||||
|
11. **Interpolation** — add the two-snapshot buffer + clock sync. Verify smoothness on a throttled network (Chrome devtools "Slow 3G").
|
||||||
|
12. **Multiplayer** — open two browser tabs. Verify two players can see each other and collide with each other's trails.
|
||||||
|
13. **Explosions + sound** — port `sprites.js`, `sound.js`. Death plays explosion + sprite.
|
||||||
|
14. **Menu + death modal + debug overlay + leaderboard** — port the UI from `App.vue` and `Game.vue` to plain HTML.
|
||||||
|
15. **Named-player faces** (e/q/r/t) — port `playerFace` rendering.
|
||||||
|
16. **Dockerfile + docker-compose** — multi-stage Rust build, copy client in.
|
||||||
|
17. **Delete old `client/` and `server/` TS projects.**
|
||||||
|
|
||||||
|
Stop after step 12 and you have a playable game. Steps 13–15 are polish.
|
||||||
|
|
||||||
|
## Things to carry over from the old code
|
||||||
|
|
||||||
|
- 60 Hz tick, `wallUpdate=3`, `playerSpeed=5`, `arenaSize=1000`, `playerSize=10`, `wallSize=8`.
|
||||||
|
- Mouse-aim angle input (`atan2(dy, dx)` from canvas center).
|
||||||
|
- Camera follows local player, centered (`canvasW/2 - camera.x`, `canvasH/2 - camera.y`).
|
||||||
|
- Random hex color for each player (but use `padStart(6, '0')` — current `.substr(1,6)` can produce malformed colors when the random number is small).
|
||||||
|
- Named-player face rendering for players named exactly `e`, `q`, `r`, or `t`.
|
||||||
|
|
||||||
|
## Things to drop
|
||||||
|
|
||||||
|
- Vue, Vuex, Buefy, Babel, vue-cli, PWA, service worker.
|
||||||
|
- `wallsBezier` (was already disabled in `Game.vue`).
|
||||||
|
- The `setInterval(..., 10000000)` + immediate `clearInterval` constructor no-op.
|
||||||
|
|
||||||
|
## Assumptions to confirm
|
||||||
|
|
||||||
|
- WebSocket path stays `/socket` (current prod URL is `wss://tronio.gltronic.ovh/socket`).
|
||||||
|
- `wallUpdate=3` in the old code meant "add a point every 3 ticks". In the new model, the open segment extends every tick while straight; the `wallUpdate` concept disappears. If you want score to grow at the same rate as before, increment score every 3 ticks while alive rather than on segment finalization.
|
||||||
|
- Named-player faces (`e`/`q`/`r`/`t.png`) are a cosmetic thing for specific player names, not a gameplay mechanic. Keep the name check.
|
||||||
|
- No auth, no rooms, no persistent scoreboard across server restarts (matches current behavior).
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 887 B After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -1,30 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="641.000000pt" height="641.000000pt" viewBox="0 0 641.000000 641.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<metadata>
|
|
||||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
|
||||||
</metadata>
|
|
||||||
<g transform="translate(0.000000,641.000000) scale(0.100000,-0.100000)"
|
|
||||||
fill="#000000" stroke="none">
|
|
||||||
<path d="M2999 6406 c-2 -2 -40 -6 -84 -10 -44 -4 -93 -9 -110 -11 -16 -3 -48
|
|
||||||
-7 -70 -10 -76 -10 -160 -27 -265 -51 -58 -14 -113 -27 -123 -29 -47 -10 -324
|
|
||||||
-108 -403 -143 -124 -55 -290 -138 -324 -162 -15 -11 -31 -20 -35 -20 -5 0
|
|
||||||
-212 -135 -285 -187 -119 -84 -373 -316 -490 -448 -343 -385 -598 -871 -717
|
|
||||||
-1364 -28 -119 -31 -133 -39 -181 -3 -19 -7 -42 -9 -50 -2 -8 -7 -37 -11 -65
|
|
||||||
-3 -27 -8 -59 -10 -69 -12 -63 -19 -219 -19 -411 1 -219 5 -287 29 -465 3 -21
|
|
||||||
18 -102 42 -224 37 -195 156 -523 271 -750 90 -176 260 -444 332 -523 9 -10
|
|
||||||
23 -28 32 -40 44 -64 242 -273 359 -378 299 -271 697 -505 1080 -635 109 -37
|
|
||||||
143 -48 153 -50 1 0 43 -12 92 -25 50 -14 104 -28 120 -31 17 -3 62 -11 100
|
|
||||||
-19 39 -7 92 -16 118 -20 27 -4 58 -8 69 -10 62 -12 209 -19 403 -19 239 0
|
|
||||||
317 6 500 35 92 14 116 18 165 29 25 6 56 12 69 15 13 2 38 9 56 15 18 6 42
|
|
||||||
12 54 15 114 22 416 138 599 230 387 194 709 450 1017 806 55 63 155 197 155
|
|
||||||
207 0 6 4 12 9 14 29 11 233 367 302 528 22 52 47 109 54 125 50 114 126 371
|
|
||||||
160 540 15 74 35 198 41 250 2 22 7 67 11 100 15 140 7 559 -13 705 -36 252
|
|
||||||
-122 589 -194 755 -9 22 -32 74 -50 115 -71 163 -206 412 -259 476 -4 5 -14
|
|
||||||
21 -21 34 -7 13 -17 29 -21 34 -5 6 -41 54 -80 106 -382 509 -946 912 -1554
|
|
||||||
1110 -217 70 -396 109 -670 145 -45 6 -531 16 -536 11z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<Game v-if="isLoggedIn && isSocketConnected"/>
|
<Game v-if="isLoggedIn && isSocketConnected" v-bind:showDebug="debugInfos" v-bind:showLeaderboard="leaderboard"/>
|
||||||
<div v-else class="container mainMenu">
|
<div v-else class="container mainMenu">
|
||||||
<img src="./assets/logo.png" alt="TronIo logo" width="150px"/>
|
<div>
|
||||||
<h1 class="title">tron.io</h1>
|
<h1 class="gameTitle">tron.i</h1>
|
||||||
<b-button @click="loginPrompt">Start</b-button>
|
<img src="./assets/logo_invert.png" alt="TronIo logo" width="110px" class="gameLogo"/>
|
||||||
|
</div>
|
||||||
|
<b-button @click="loginPrompt" class="startButton">Start</b-button>
|
||||||
<hr>
|
<hr>
|
||||||
<b-field label="Music" position="is-centered">
|
<b-field label="Music" position="is-centered">
|
||||||
<b-radio-button v-model="music" native-value="1">
|
<b-radio-button v-model="music" native-value="1">
|
||||||
@@ -14,6 +16,8 @@
|
|||||||
None
|
None
|
||||||
</b-radio-button>
|
</b-radio-button>
|
||||||
</b-field>
|
</b-field>
|
||||||
|
<b-switch v-model="debugInfos">Debug info</b-switch>
|
||||||
|
<b-switch v-model="leaderboard">Leaderboard</b-switch>
|
||||||
</div>
|
</div>
|
||||||
<b-loading v-model="isLoading"/>
|
<b-loading v-model="isLoading"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +35,9 @@ export default {
|
|||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
music: '2'
|
music: '2',
|
||||||
|
debugInfos: false,
|
||||||
|
leaderboard: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -79,11 +85,27 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
#app {
|
#app {
|
||||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #2c3e50;
|
color: rgb(235, 226, 233);
|
||||||
|
background-color: #2a374a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameLogo {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gameTitle {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: TRON;
|
||||||
|
font-size: 110px;
|
||||||
|
color: #cf0cb4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startButton {
|
||||||
|
margin-top: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mainMenu {
|
.mainMenu {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -2,8 +2,13 @@
|
|||||||
@import url("https://fonts.googleapis.com/css?family=Lato:400,700,400italic&display=swap");
|
@import url("https://fonts.googleapis.com/css?family=Lato:400,700,400italic&display=swap");
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Gravity Regular';
|
font-family: 'TRON';
|
||||||
src: url('Gravity-Regular.otf') format('opentype');
|
src: url('TRON.TTF') format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color: rgb(235, 226, 233);
|
||||||
|
background-color: #2a374a;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
@@ -68,6 +73,10 @@ a {
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
&:after,
|
&:after,
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ $blue: #3498db;
|
|||||||
$purple: #8e44ad;
|
$purple: #8e44ad;
|
||||||
$red: #e74c3c;
|
$red: #e74c3c;
|
||||||
$white-ter: #ecf0f1;
|
$white-ter: #ecf0f1;
|
||||||
$primary: $purple;
|
$primary: #cf0cb4ff;
|
||||||
$yellow-invert: #fff;
|
$yellow-invert: #fff;
|
||||||
|
|
||||||
$family-sans-serif: "Gravity Regular", "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
$family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
"Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
"Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||||
$family-monospace: "Inconsolata", "Consolas", "Monaco", monospace;
|
$family-monospace: "Inconsolata", "Consolas", "Monaco", monospace;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="game">
|
<div class="game">
|
||||||
<b-modal v-model="playerIsDead">
|
<b-modal v-model="playerIsDead">
|
||||||
<h1 class="title">DED</h1>
|
<h1 class="titleDed">DED</h1>
|
||||||
<h2 class="title">Score: {{player.score}}</h2>
|
<h2 class="title">Score: {{player.score}}</h2>
|
||||||
<h3 class="subtitle">Best score: {{player.bestScore}}</h3>
|
<h3 class="subtitle">Best score: {{player.bestScore}}</h3>
|
||||||
<b-button @click="respawn">Respawn</b-button>
|
<b-button @click="respawn">Respawn</b-button>
|
||||||
|
<b-button @click="quit">Quit</b-button>
|
||||||
</b-modal>
|
</b-modal>
|
||||||
<canvas
|
<canvas
|
||||||
class="game-canvas"
|
class="game-canvas"
|
||||||
@@ -16,11 +17,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { send } from '@/store/socketPlugin'
|
import { send } from '@/store/socketPlugin'
|
||||||
import { render } from '@/game/render.js'
|
import { render } from '@/game/render.js'
|
||||||
// import { update } from '@/game/update.js'
|
|
||||||
import { sound } from '@/game/sound.js'
|
import { sound } from '@/game/sound.js'
|
||||||
|
import { Explosion } from '@/game/sprites.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Game',
|
name: 'Game',
|
||||||
|
props: ['showDebug', 'showLeaderboard'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
mouse: {
|
mouse: {
|
||||||
@@ -34,8 +36,10 @@ export default {
|
|||||||
stats: {
|
stats: {
|
||||||
totalWalls: 0,
|
totalWalls: 0,
|
||||||
lastUpdateTime: {},
|
lastUpdateTime: {},
|
||||||
lastFrame: 0
|
lastFrame: 0,
|
||||||
|
lastSpriteFrame: 0
|
||||||
},
|
},
|
||||||
|
deadPlayers: [],
|
||||||
renderTimer: null
|
renderTimer: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -52,6 +56,9 @@ export default {
|
|||||||
player () {
|
player () {
|
||||||
return this.$store.state.game.player
|
return this.$store.state.game.player
|
||||||
},
|
},
|
||||||
|
sprites () {
|
||||||
|
return this.$store.state.game.sprites
|
||||||
|
},
|
||||||
players () {
|
players () {
|
||||||
const pastUpdate = this.$store.state.game.updates[0]
|
const pastUpdate = this.$store.state.game.updates[0]
|
||||||
const nextUpdate = this.$store.state.game.updates[1]
|
const nextUpdate = this.$store.state.game.updates[1]
|
||||||
@@ -69,7 +76,6 @@ export default {
|
|||||||
return this.$store.state.game.leaderboard
|
return this.$store.state.game.leaderboard
|
||||||
},
|
},
|
||||||
playerIsDead () {
|
playerIsDead () {
|
||||||
if (this.player.state === 'DEAD') sound.explosion()
|
|
||||||
return this.player.state === 'DEAD'
|
return this.player.state === 'DEAD'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -78,7 +84,8 @@ export default {
|
|||||||
this.canvas.addEventListener('mousemove', this.mouseEvent)
|
this.canvas.addEventListener('mousemove', this.mouseEvent)
|
||||||
this.canvas.addEventListener('touchmove', this.touchEvent)
|
this.canvas.addEventListener('touchmove', this.touchEvent)
|
||||||
this.canvas.addEventListener('resize', this.setCanvasSize)
|
this.canvas.addEventListener('resize', this.setCanvasSize)
|
||||||
// this.renderTimer = setInterval(this.render, 1000 / 120)
|
// this.renderTimer = setInterval(this.render, 1000 / 60)
|
||||||
|
// this.renderTimer = setInterval(this.renderSprites, 1000 / 30)
|
||||||
this.render()
|
this.render()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -108,19 +115,64 @@ export default {
|
|||||||
if (player.state === 'DEAD') this.context.globalAlpha = 1
|
if (player.state === 'DEAD') this.context.globalAlpha = 1
|
||||||
|
|
||||||
this.stats.totalWalls += player.walls.length
|
this.stats.totalWalls += player.walls.length
|
||||||
|
|
||||||
|
// Check if player was dead before for one time event
|
||||||
|
if (player.state === 'DEAD' && !this.deadPlayers.includes(player.id)) {
|
||||||
|
this.deadPlayers.push(player.id)
|
||||||
|
sound.explosion()
|
||||||
|
this.sprites.push(new Explosion(player.x, player.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove player from dead list
|
||||||
|
if (player.state !== 'DEAD' && this.deadPlayers.includes(player.id)) {
|
||||||
|
const i = this.deadPlayers.indexOf(player.id)
|
||||||
|
this.deadPlayers.splice(i, 1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
render.leaderboard(this.context, this.canvas, this.leaderboard)
|
this.renderSprites()
|
||||||
|
|
||||||
render.mouse(this.context, this.mouse, this.player)
|
render.mouse(this.context, this.mouse, this.player)
|
||||||
|
|
||||||
|
if (this.showLeaderboard) {
|
||||||
|
render.leaderboard(this.context, this.canvas, this.leaderboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.showDebug) {
|
||||||
render.debug(this.context, this.camera, this.mouse, this.canvas, this.stats)
|
render.debug(this.context, this.camera, this.mouse, this.canvas, this.stats)
|
||||||
|
}
|
||||||
|
|
||||||
this.stats.lastFrame = performance.now()
|
this.stats.lastFrame = performance.now()
|
||||||
|
|
||||||
const nextUpdate = this.$store.state.game.updates[1]
|
const nextUpdate = this.$store.state.game.updates[1]
|
||||||
if (nextUpdate !== undefined) this.stats.lastUpdateTime = nextUpdate.time
|
if (nextUpdate !== undefined) this.stats.lastUpdateTime = nextUpdate.time
|
||||||
|
|
||||||
requestAnimationFrame(this.render, this.canvas)
|
requestAnimationFrame(this.render, this.canvas)
|
||||||
},
|
},
|
||||||
|
renderSprites () {
|
||||||
|
let nextFrame = false
|
||||||
|
// Check if needed to show next frame for animations
|
||||||
|
if ((performance.now() - this.stats.lastSpriteFrame) > 50) {
|
||||||
|
nextFrame = true
|
||||||
|
this.stats.lastSpriteFrame = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sprites.forEach((sprite, index, object) => {
|
||||||
|
render.sprite(this.context, this.canvas, this.camera, sprite)
|
||||||
|
if (nextFrame) {
|
||||||
|
if (sprite.framesCounter === sprite.frames) {
|
||||||
|
if (sprite.removeAfterAnimation) {
|
||||||
|
object.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
// console.log('SPRITE ', index, 'frame reset')
|
||||||
|
sprite.framesCounter = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.log('SPRITE ', index, 'frame++', sprite.framesCounter)
|
||||||
|
sprite.framesCounter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
mouseEvent (event) {
|
mouseEvent (event) {
|
||||||
var rect = this.canvas.getBoundingClientRect()
|
var rect = this.canvas.getBoundingClientRect()
|
||||||
this.mouse.x = event.clientX - rect.left
|
this.mouse.x = event.clientX - rect.left
|
||||||
@@ -169,7 +221,21 @@ export default {
|
|||||||
},
|
},
|
||||||
respawn () {
|
respawn () {
|
||||||
send({ type: 'respawn' })
|
send({ type: 'respawn' })
|
||||||
|
},
|
||||||
|
quit () {
|
||||||
|
send({ type: 'logout' })
|
||||||
|
this.$store.dispatch('game/logout')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.titleDed {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: TRON;
|
||||||
|
font-size: 70px;
|
||||||
|
color: #cf0cb4ff;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const render = {
|
|||||||
context.beginPath()
|
context.beginPath()
|
||||||
context.lineWidth = settings.wallSize
|
context.lineWidth = settings.wallSize
|
||||||
context.strokeStyle = player.color
|
context.strokeStyle = player.color
|
||||||
// context.shadowBlur = 0
|
// context.shadowBlur = 0.5
|
||||||
// context.shadowColor = player.color
|
// context.shadowColor = player.color
|
||||||
player.walls.forEach(wall => {
|
player.walls.forEach(wall => {
|
||||||
const canvasX = canvas.width / 2 + wall.x - camera.x
|
const canvasX = canvas.width / 2 + wall.x - camera.x
|
||||||
@@ -120,14 +120,36 @@ export const render = {
|
|||||||
context.stroke()
|
context.stroke()
|
||||||
},
|
},
|
||||||
leaderboard (context, canvas, leaderboard) {
|
leaderboard (context, canvas, leaderboard) {
|
||||||
|
context.globalAlpha = 0.1
|
||||||
|
context.fillStyle = 'white'
|
||||||
|
context.fillRect(canvas.width - 160, 35, 120, 20 + leaderboard.length * 5)
|
||||||
|
context.globalAlpha = 1
|
||||||
|
|
||||||
context.fillStyle = 'white'
|
context.fillStyle = 'white'
|
||||||
context.textAlign = 'end'
|
context.textAlign = 'end'
|
||||||
context.fillText('Leaderboard: ', canvas.width - 50, 10)
|
context.fillText('Leaderboard: ', canvas.width - 40, 30)
|
||||||
var i = 1
|
var i = 1
|
||||||
leaderboard.forEach(player => {
|
leaderboard.forEach(player => {
|
||||||
context.fillStyle = player.color
|
context.fillStyle = player.color
|
||||||
context.fillText(player.name + ' - ' + player.score + ' (' + player.bestScore + ')', canvas.width - 50, 15 + i * 10)
|
context.fillText(player.name + ' - ' + player.score + ' (' + player.bestScore + ')', canvas.width - 50, 40 + i * 10)
|
||||||
i++
|
i++
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
sprite (context, canvas, camera, sprite) {
|
||||||
|
const canvasX = canvas.width / 2 + sprite.x - camera.x
|
||||||
|
const canvasY = canvas.height / 2 + sprite.y - camera.y
|
||||||
|
|
||||||
|
const posX = canvasX - sprite.sizeX * 2
|
||||||
|
const posY = canvasY - sprite.sizeY * 2
|
||||||
|
const sizeL = sprite.sizeX * 4
|
||||||
|
const sizeH = sprite.sizeY * 4
|
||||||
|
|
||||||
|
if (sprite.isAnimated) {
|
||||||
|
const frameX = sprite.heightX * (sprite.framesCounter % 8)
|
||||||
|
const frameY = sprite.heightY * Math.floor(sprite.framesCounter / 8)
|
||||||
|
context.drawImage(sprite.image, frameX, frameY, sprite.heightX, sprite.heightY, posX, posY, sizeL, sizeH)
|
||||||
|
} else {
|
||||||
|
context.drawImage(sprite.image, posX, posY, sizeL, sizeH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export class Player {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sprite {
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
sizeX = 0;
|
||||||
|
sizeY = 0;
|
||||||
|
heightX = 0;
|
||||||
|
heightY = 0;
|
||||||
|
image = new Image();
|
||||||
|
isAnimated = false;
|
||||||
|
removeAfterAnimation = false;
|
||||||
|
frames = 0;
|
||||||
|
framesCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Wall {
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Explosion extends Sprite {
|
||||||
|
constructor (x, y) {
|
||||||
|
super()
|
||||||
|
this.x = x
|
||||||
|
this.y = y
|
||||||
|
this.sizeX = 50
|
||||||
|
this.sizeY = 50
|
||||||
|
this.heightX = 256
|
||||||
|
this.heightY = 256
|
||||||
|
this.image = new Image()
|
||||||
|
this.isAnimated = true
|
||||||
|
this.removeAfterAnimation = true
|
||||||
|
this.frames = 32
|
||||||
|
this.framesCounter = 0
|
||||||
|
|
||||||
|
this.image.src = require('@/assets/sprite/explosion.png')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const state = {
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
state: 'DEAD'
|
state: 'DEAD'
|
||||||
},
|
},
|
||||||
|
sprites: [],
|
||||||
leaderboard: [],
|
leaderboard: [],
|
||||||
updates: [],
|
updates: [],
|
||||||
firstUpdateTime: null,
|
firstUpdateTime: null,
|
||||||
@@ -36,6 +37,10 @@ const actions = {
|
|||||||
commit('SET_PLAYER', player)
|
commit('SET_PLAYER', player)
|
||||||
commit('SET_LOGIN', true)
|
commit('SET_LOGIN', true)
|
||||||
},
|
},
|
||||||
|
logout ({ commit }) {
|
||||||
|
commit('SET_LOGIN', false)
|
||||||
|
commit('CLEAR_UPDATE')
|
||||||
|
},
|
||||||
settings ({ commit }, settings) {
|
settings ({ commit }, settings) {
|
||||||
commit('SET_SETTINGS', settings)
|
commit('SET_SETTINGS', settings)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ToastProgrammatic as Toast } from 'buefy'
|
import { ToastProgrammatic as Toast } from 'buefy'
|
||||||
|
|
||||||
// const connection = new WebSocket('ws://localhost:3000/socket')
|
|
||||||
const connection = new WebSocket('wss://tronio.gltronic.ovh/socket')
|
const connection = new WebSocket('wss://tronio.gltronic.ovh/socket')
|
||||||
|
// const connection = new WebSocket('ws://localhost:3000/socket')
|
||||||
|
|
||||||
export default function createSocketPlugin () {
|
export default function createSocketPlugin () {
|
||||||
return store => {
|
return store => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
version: '3'
|
---
|
||||||
|
version: "2"
|
||||||
services:
|
services:
|
||||||
tronio:
|
tronio:
|
||||||
image: gltron/tronio
|
image: dockerreg.gltronic.ovh/tronio
|
||||||
container_name: tronio
|
container_name: tronio
|
||||||
ports:
|
ports:
|
||||||
- 8006:8080
|
- 8006:8080
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
|
- SERVER=tronio.gltronic.ovh
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"commonjs": true,
|
|
||||||
"es2021": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"standard"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 12
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<projectDescription>
|
|
||||||
<name>tronio</name>
|
|
||||||
<comment></comment>
|
|
||||||
<projects>
|
|
||||||
</projects>
|
|
||||||
<buildSpec>
|
|
||||||
<buildCommand>
|
|
||||||
<name>org.eclipse.m2e.core.maven2Builder</name>
|
|
||||||
<arguments>
|
|
||||||
</arguments>
|
|
||||||
</buildCommand>
|
|
||||||
</buildSpec>
|
|
||||||
<natures>
|
|
||||||
<nature>org.eclipse.m2e.core.maven2Nature</nature>
|
|
||||||
</natures>
|
|
||||||
<filteredResources>
|
|
||||||
<filter>
|
|
||||||
<id>1599131612586</id>
|
|
||||||
<name></name>
|
|
||||||
<type>30</type>
|
|
||||||
<matcher>
|
|
||||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
|
||||||
<arguments>node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
|
||||||
</matcher>
|
|
||||||
</filter>
|
|
||||||
</filteredResources>
|
|
||||||
</projectDescription>
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "tronio-server",
|
"name": "tronio-server",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "Tron.io game server",
|
"description": "Tron.io game server",
|
||||||
"main": "server.js",
|
"main": "dist/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "tsc && node dist/app.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"build": "tsc",
|
||||||
|
"lint": "echo \"Warning: no lint specified\"",
|
||||||
|
"test": "echo \"Warning: no test specified\""
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -19,10 +21,10 @@
|
|||||||
"ws": "^7.3.1"
|
"ws": "^7.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^7.12.1",
|
"@types/express": "^4.17.11",
|
||||||
"eslint-config-standard": "^16.0.0",
|
"@types/node": "^14.14.22",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"@types/uuid": "^8.3.0",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"@types/ws": "^7.4.0",
|
||||||
"eslint-plugin-promise": "^4.2.1"
|
"typescript": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { Player } from './models/Player';
|
||||||
|
import { GameSettings } from './models/GameSettings';
|
||||||
|
import { GameWebSocket } from './models/GameWebSocket';
|
||||||
|
|
||||||
|
export class Game {
|
||||||
|
private gameSettings = new GameSettings();
|
||||||
|
private players = new Map<string, Player>();
|
||||||
|
private sockets = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
private computeUpdates = false;
|
||||||
|
private updateInterval = setInterval(() => this.step(), 10000000);
|
||||||
|
private lastUpdateTime = Date.now();
|
||||||
|
private doUpdate = true;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
public login (connection: GameWebSocket, name: string): void {
|
||||||
|
console.log('[GAME] Player', name, 'connected |', this.players.size + 1);
|
||||||
|
const id = uuidv4();
|
||||||
|
connection.id = id;
|
||||||
|
connection.name = name;
|
||||||
|
this.sockets.set(id, connection);
|
||||||
|
this.players.set(id, new Player(id, name));
|
||||||
|
|
||||||
|
connection.send(JSON.stringify({
|
||||||
|
type: 'login',
|
||||||
|
player: this.players.get(id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
connection.send(JSON.stringify({
|
||||||
|
type: 'gameSettings',
|
||||||
|
gameSettings: this.gameSettings
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!this.computeUpdates) {
|
||||||
|
console.log('[GAME] Starting updates');
|
||||||
|
this.updateInterval = setInterval(() => this.step(), 1000 / 60);
|
||||||
|
this.computeUpdates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout (connection: GameWebSocket): void {
|
||||||
|
console.log('[GAME] Player', connection.name, 'loggedout |', this.players.size - 1);
|
||||||
|
this.players.delete(connection.id);
|
||||||
|
|
||||||
|
if (this.players.size === 0) {
|
||||||
|
console.log('[GAME] Stoping updates');
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
this.computeUpdates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnect (connection: GameWebSocket): void {
|
||||||
|
console.log('[GAME] Player', connection.name, 'disconnected |', this.sockets.size - 1);
|
||||||
|
this.sockets.delete(connection.id);
|
||||||
|
this.logout(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public respawn (connection: GameWebSocket): void {
|
||||||
|
console.log('[GAME] Player ' + connection.name + ' respawned');
|
||||||
|
this.players.get(connection.id)?.reset();
|
||||||
|
|
||||||
|
connection.send(JSON.stringify({
|
||||||
|
type: 'gamePlayerSpawn',
|
||||||
|
player: this.players.get(connection.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
public update (connection: GameWebSocket, player: Player): void {
|
||||||
|
const playerToUpdate = this.players.get(connection.id);
|
||||||
|
if (playerToUpdate) playerToUpdate.angle = player.angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public kill (player: Player): void {
|
||||||
|
console.log('[GAME] Player', player.name, 'died');
|
||||||
|
player.kill();
|
||||||
|
this.sockets.get(player.id)?.send(JSON.stringify({
|
||||||
|
type: 'gamePlayerDead',
|
||||||
|
player: player
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private step (): void {
|
||||||
|
const currentTime = Date.now()
|
||||||
|
const durationSinceLastUpdate = (currentTime - this.lastUpdateTime) / 1000;
|
||||||
|
const tickToSimulate = (durationSinceLastUpdate * 60) / 1000;
|
||||||
|
this.lastUpdateTime = currentTime;
|
||||||
|
|
||||||
|
// console.log('UPDATE ' + currentTime + ' doUpdate ' + doUpdate)
|
||||||
|
|
||||||
|
this.players.forEach((player, id) => {
|
||||||
|
if (player.state === 'DEAD') return
|
||||||
|
|
||||||
|
if (player.isOutOfBorders()) this.kill(player);
|
||||||
|
|
||||||
|
this.players.forEach((player2, id2) => {
|
||||||
|
for (let i = 0; i < player2.walls.length - 2; i++) {
|
||||||
|
// Prevent self destroy on last wall
|
||||||
|
if (player === player2 && i >= player2.walls.length - 1) break
|
||||||
|
|
||||||
|
const wallA = player2.walls[i]
|
||||||
|
const wallB = player2.walls[i + 1]
|
||||||
|
|
||||||
|
if (player.isCloseToWall(wallA, wallB)) {
|
||||||
|
if (player.isCrossingLine(wallA, wallB)) {
|
||||||
|
if (player !== player2) player2.score += 300
|
||||||
|
this.kill(player);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.players.forEach((player, id) => {
|
||||||
|
player.step(tickToSimulate);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.doUpdate) this.broadcastUpdate();
|
||||||
|
this.doUpdate = !this.doUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastUpdate () {
|
||||||
|
const update = {
|
||||||
|
type: 'gameUpdate',
|
||||||
|
players: [ ...this.players.values() ],
|
||||||
|
time: this.lastUpdateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sockets.forEach((connection, id) => {
|
||||||
|
connection.send(JSON.stringify(update));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { Game } from './Game';
|
||||||
|
import WebSocket, { Server } from 'ws';
|
||||||
|
import { GameWebSocket } from './models/GameWebSocket';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const WebSocketServer = Server;
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(express.static('distClient/'));
|
||||||
|
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`Tron.io running on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
const game = new Game();
|
||||||
|
|
||||||
|
wss.on('connection', function (connection: WebSocket) {
|
||||||
|
connection.on('close', () => game.disconnect(connection as GameWebSocket));
|
||||||
|
|
||||||
|
connection.on('message', ( message ) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message as string);
|
||||||
|
switch (data.type) {
|
||||||
|
case 'login': game.login(connection as GameWebSocket, data.name); break;
|
||||||
|
case 'logout': game.logout(connection as GameWebSocket); break;
|
||||||
|
case 'respawn': game.respawn(connection as GameWebSocket); break;
|
||||||
|
case 'update': game.update(connection as GameWebSocket, data.player); break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
const { v4: uuidv4 } = require('uuid')
|
|
||||||
const Player = require('./models/player')
|
|
||||||
const gameSettings = require('./models/gameSettings')
|
|
||||||
|
|
||||||
const players = {}
|
|
||||||
const sockets = {}
|
|
||||||
|
|
||||||
let updateInterval = -1
|
|
||||||
|
|
||||||
let lastUpdateTime = Date.now()
|
|
||||||
let doUpdate = true
|
|
||||||
|
|
||||||
function login (connection, name) {
|
|
||||||
const id = uuidv4()
|
|
||||||
connection.id = id
|
|
||||||
sockets[id] = connection
|
|
||||||
players[id] = new Player(id, name)
|
|
||||||
|
|
||||||
connection.send(JSON.stringify({
|
|
||||||
type: 'login',
|
|
||||||
player: players[id]
|
|
||||||
}))
|
|
||||||
|
|
||||||
connection.send(JSON.stringify({
|
|
||||||
type: 'gameSettings',
|
|
||||||
gameSettings: gameSettings
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (updateInterval === -1) updateInterval = setInterval(() => step(), 1000 / 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout (connection) {
|
|
||||||
delete sockets[connection.id]
|
|
||||||
delete players[connection.id]
|
|
||||||
|
|
||||||
if (Object.keys(players).length === 0) {
|
|
||||||
clearInterval(updateInterval)
|
|
||||||
updateInterval = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function respawn (connection) {
|
|
||||||
players[connection.id].reset()
|
|
||||||
|
|
||||||
connection.send(JSON.stringify({
|
|
||||||
type: 'gamePlayerSpawn',
|
|
||||||
player: players[connection.id]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function update (connection, player) {
|
|
||||||
players[connection.id].angle = player.angle
|
|
||||||
}
|
|
||||||
|
|
||||||
function kill (player) {
|
|
||||||
player.kill()
|
|
||||||
sockets[player.id].send(JSON.stringify({
|
|
||||||
type: 'gamePlayerDead',
|
|
||||||
player: player
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function step () {
|
|
||||||
const currentTime = Date.now()
|
|
||||||
const durationSinceLastUpdate = (currentTime - lastUpdateTime) / 1000
|
|
||||||
const tickToSimulate = (durationSinceLastUpdate * 60) / 1000
|
|
||||||
lastUpdateTime = currentTime
|
|
||||||
|
|
||||||
// console.log('UPDATE ' + currentTime + ' doUpdate ' + doUpdate)
|
|
||||||
|
|
||||||
Object.values(players).forEach((player) => {
|
|
||||||
if (player.isOutOfBorders()) kill(player)
|
|
||||||
|
|
||||||
Object.values(players).forEach((player2) => {
|
|
||||||
if (player.state === 'DEAD') return
|
|
||||||
|
|
||||||
for (let i = 0; i < player2.walls.length - 2; i++) {
|
|
||||||
// Prevent self destroy on last wall
|
|
||||||
if (player === player2 && i >= player2.walls.length - 1) break
|
|
||||||
|
|
||||||
const wallA = player2.walls[i]
|
|
||||||
const wallB = player2.walls[i + 1]
|
|
||||||
|
|
||||||
if (player.isCloseToWall(wallA, wallB)) {
|
|
||||||
if (player.isCrossingLine(wallA, wallB)) {
|
|
||||||
if (player !== player2) player2.score += 300
|
|
||||||
kill(player)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.values(players).forEach((player) => {
|
|
||||||
player.step(tickToSimulate)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (doUpdate) broadcastUpdate()
|
|
||||||
doUpdate = !doUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastUpdate () {
|
|
||||||
const update = {
|
|
||||||
type: 'gameUpdate',
|
|
||||||
players: Object.values(players),
|
|
||||||
time: lastUpdateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.values(sockets).forEach((connection) => {
|
|
||||||
connection.send(JSON.stringify(update))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
respawn,
|
|
||||||
update
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class GameSettings {
|
||||||
|
playerSize = 10;
|
||||||
|
playerSpeed = 5;
|
||||||
|
playerTurnSpeed = 10;
|
||||||
|
wallSize = 8;
|
||||||
|
wallUpdate = 3;
|
||||||
|
arenaSize = 1000;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
export interface GameWebSocket extends WebSocket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Wall } from './Wall';
|
||||||
|
import { GameSettings } from './GameSettings';
|
||||||
|
|
||||||
|
export class Player {
|
||||||
|
|
||||||
|
private gameSettings = new GameSettings();
|
||||||
|
public bestScore = 0;
|
||||||
|
public angle = 0;
|
||||||
|
public score = 0;
|
||||||
|
public color = '#' + (0x1000000 + (Math.random()) * 0xffffff).toString(16).substr(1, 6);
|
||||||
|
public x = this.gameSettings.arenaSize * (0.25 + Math.random() * 0.5);
|
||||||
|
public y = this.gameSettings.arenaSize * (0.25 + Math.random() * 0.5);
|
||||||
|
public walls: Wall[] = [];
|
||||||
|
public lastWall = 0;
|
||||||
|
public state = 'ALIVE';
|
||||||
|
|
||||||
|
constructor (public id: string, public name: string) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset () {
|
||||||
|
this.score = 0;
|
||||||
|
this.color = '#' + (0x1000000 + (Math.random()) * 0xffffff).toString(16).substr(1, 6);
|
||||||
|
this.x = this.gameSettings.arenaSize * (0.25 + Math.random() * 0.5);
|
||||||
|
this.y = this.gameSettings.arenaSize * (0.25 + Math.random() * 0.5);
|
||||||
|
this.walls = [];
|
||||||
|
this.lastWall = 0;
|
||||||
|
this.state = 'ALIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
kill () {
|
||||||
|
this.state = 'DEAD';
|
||||||
|
if (this.bestScore < this.score) this.bestScore = this.score;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOutOfBorders () {
|
||||||
|
return this.x - this.gameSettings.playerSize < 0 ||
|
||||||
|
this.x + this.gameSettings.playerSize > this.gameSettings.arenaSize ||
|
||||||
|
this.y - this.gameSettings.playerSize < 0 ||
|
||||||
|
this.y + this.gameSettings.playerSize > this.gameSettings.arenaSize
|
||||||
|
}
|
||||||
|
|
||||||
|
isCloseToWall (wallA: Wall, wallB: Wall) {
|
||||||
|
const xar = Math.min(wallA.x, wallB.x) - this.gameSettings.playerSize
|
||||||
|
const yar = Math.min(wallA.y, wallB.y) - this.gameSettings.playerSize
|
||||||
|
const xbr = Math.min(wallA.x, wallB.x) + this.gameSettings.playerSize
|
||||||
|
const ybr = Math.min(wallA.y, wallB.y) + this.gameSettings.playerSize
|
||||||
|
|
||||||
|
return ((this.x >= xar && this.x <= xbr) && (this.y >= yar && this.y <= ybr))
|
||||||
|
}
|
||||||
|
|
||||||
|
isCrossingLine (wallA: Wall, wallB: Wall) {
|
||||||
|
const xa = wallA.x
|
||||||
|
const ya = wallA.y
|
||||||
|
const xb = wallB.x
|
||||||
|
const yb = wallB.y
|
||||||
|
const xc = this.x
|
||||||
|
const yc = this.y
|
||||||
|
const radius = this.gameSettings.playerSize
|
||||||
|
|
||||||
|
return Math.abs((yb - ya) * xc - (xb - xa) * yc + xb * ya - yb * xa) / Math.sqrt(Math.pow(xb - xa, 2) + Math.pow(yb - ya, 2)) < radius
|
||||||
|
}
|
||||||
|
|
||||||
|
step (tickToSimulate: number) {
|
||||||
|
if (this.state === 'DEAD') return
|
||||||
|
|
||||||
|
for (let i = 0; i < tickToSimulate; i++) {
|
||||||
|
this.lastWall++
|
||||||
|
|
||||||
|
if (this.lastWall > this.gameSettings.wallUpdate) {
|
||||||
|
this.walls.push(new Wall(this.x, this.y))
|
||||||
|
this.score++
|
||||||
|
this.lastWall = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.x = this.x + this.gameSettings.playerSpeed * Math.cos(this.angle)
|
||||||
|
this.y = this.y + this.gameSettings.playerSpeed * Math.sin(this.angle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class Wall {
|
||||||
|
constructor (public x: number, public y: number) { }
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module.exports = Object.freeze({
|
|
||||||
playerSize: 10,
|
|
||||||
playerSpeed: 5,
|
|
||||||
playerTurnSpeed: 10,
|
|
||||||
wallSize: 8,
|
|
||||||
wallUpdate: 5,
|
|
||||||
arenaSize: 1000
|
|
||||||
})
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
const Wall = require('./wall')
|
|
||||||
const gameSettings = require('./gameSettings')
|
|
||||||
|
|
||||||
class Player {
|
|
||||||
constructor (id, name) {
|
|
||||||
this.id = id
|
|
||||||
this.name = name
|
|
||||||
this.bestScore = 0
|
|
||||||
this.angle = 0
|
|
||||||
this.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
reset () {
|
|
||||||
this.score = 0
|
|
||||||
this.color = '#' + (0x1000000 + (Math.random()) * 0xffffff).toString(16).substr(1, 6)
|
|
||||||
this.x = gameSettings.arenaSize * (0.25 + Math.random() * 0.5)
|
|
||||||
this.y = gameSettings.arenaSize * (0.25 + Math.random() * 0.5)
|
|
||||||
this.walls = []
|
|
||||||
this.lastWall = 0
|
|
||||||
this.state = 'ALIVE'
|
|
||||||
}
|
|
||||||
|
|
||||||
kill () {
|
|
||||||
this.state = 'DEAD'
|
|
||||||
if (this.bestScore < this.score) this.bestScore = this.score
|
|
||||||
}
|
|
||||||
|
|
||||||
isOutOfBorders () {
|
|
||||||
return this.x - gameSettings.playerSize < 0 ||
|
|
||||||
this.x + gameSettings.playerSize > gameSettings.arenaSize ||
|
|
||||||
this.y - gameSettings.playerSize < 0 ||
|
|
||||||
this.y + gameSettings.playerSize > gameSettings.arenaSize
|
|
||||||
}
|
|
||||||
|
|
||||||
isCloseToWall (wallA, wallB) {
|
|
||||||
const xar = Math.min(wallA.x, wallB.x) - gameSettings.playerSize
|
|
||||||
const yar = Math.min(wallA.y, wallB.y) - gameSettings.playerSize
|
|
||||||
const xbr = Math.min(wallA.x, wallB.x) + gameSettings.playerSize
|
|
||||||
const ybr = Math.min(wallA.y, wallB.y) + gameSettings.playerSize
|
|
||||||
|
|
||||||
return ((this.x >= xar && this.x <= xbr) && (this.y >= yar && this.y <= ybr))
|
|
||||||
}
|
|
||||||
|
|
||||||
isCrossingLine (wallA, wallB) {
|
|
||||||
const xa = wallA.x
|
|
||||||
const ya = wallA.y
|
|
||||||
const xb = wallB.x
|
|
||||||
const yb = wallB.y
|
|
||||||
const xc = this.x
|
|
||||||
const yc = this.y
|
|
||||||
const radius = gameSettings.playerSize
|
|
||||||
|
|
||||||
return Math.abs((yb - ya) * xc - (xb - xa) * yc + xb * ya - yb * xa) / Math.sqrt(Math.pow(xb - xa, 2) + Math.pow(yb - ya, 2)) < radius
|
|
||||||
}
|
|
||||||
|
|
||||||
step (tickToSimulate) {
|
|
||||||
if (this.state === 'DEAD') return
|
|
||||||
|
|
||||||
for (let i = 0; i < tickToSimulate; i++) {
|
|
||||||
this.lastWall++
|
|
||||||
|
|
||||||
if (this.lastWall > gameSettings.wallUpdate) {
|
|
||||||
this.walls.push(new Wall(this.x, this.y))
|
|
||||||
this.score++
|
|
||||||
this.lastWall = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
this.x = this.x + gameSettings.playerSpeed * Math.cos(this.angle)
|
|
||||||
this.y = this.y + gameSettings.playerSpeed * Math.sin(this.angle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Player
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
class Wall {
|
|
||||||
constructor (x, y) {
|
|
||||||
this.x = x
|
|
||||||
this.y = y
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Wall
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const express = require('express')
|
|
||||||
const app = express()
|
|
||||||
const game = require('./game')
|
|
||||||
const WebSocketServer = require('ws').Server
|
|
||||||
const port = process.env.PORT || 3000
|
|
||||||
|
|
||||||
app.use(express.static('dist'))
|
|
||||||
|
|
||||||
const server = app.listen(port, () => {
|
|
||||||
console.log(`Tron.io running on port ${port}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server })
|
|
||||||
|
|
||||||
wss.on('connection', function (connection) {
|
|
||||||
connection.on('close', () => game.logout(connection))
|
|
||||||
|
|
||||||
connection.on('message', (message) => {
|
|
||||||
let data
|
|
||||||
|
|
||||||
try {
|
|
||||||
data = JSON.parse(message)
|
|
||||||
} catch (e) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.type) {
|
|
||||||
case 'login': game.login(connection, data.name); break
|
|
||||||
case 'respawn': game.respawn(connection); break
|
|
||||||
case 'update': game.update(connection, data.player); break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||