Compare commits

...
11 Commits
Author SHA1 Message Date
gltron 42dfd9eb83 Rewrite instructions 2026-07-19 18:00:04 +02:00
gltron ba2199c60b Fixed server address 2021-07-14 17:38:19 +02:00
gltron c3c6ba2f33 Improved graphics, new title screen 2021-07-14 17:23:36 +02:00
gltron 1ddd5c781d Animated sprite support, server bug fixes 2021-07-14 16:12:28 +02:00
Thomas 37a6581a27 added one space 2021-01-29 14:30:45 +01:00
Thomas c1a093cb45 quickfix for client socket connection 2021-01-29 14:20:49 +01:00
Thomas b3e8bc4889 Fixed client serving 2021-01-29 14:08:33 +01:00
Thomas f4842a9f32 refixed dockerfile 2021-01-28 18:48:43 +01:00
Thomas 5d13cb900e Fixed dockerfile 2021-01-28 18:47:56 +01:00
Thomas c66cc42118 Updated build config + Drone CI 2021-01-28 17:58:33 +01:00
Thomas cc6a474a88 Converted server to Typescript 2021-01-28 17:49:49 +01:00
63 changed files with 20606 additions and 4541 deletions
+27
View File
@@ -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
+2 -1
View File
@@ -21,4 +21,5 @@ pnpm-debug.log*
*.sln *.sln
*.sw? *.sw?
tronio.jar server/dist
client/dist
+12 -12
View File
@@ -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"]
+449
View File
@@ -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 — 10100× 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 1315 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).
+18914 -2633
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 887 B

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

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

+29 -7
View File
@@ -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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.
Binary file not shown.
+11 -2
View File
@@ -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 {
+2 -2
View File
@@ -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;
+73 -7
View File
@@ -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>
+25 -3
View File
@@ -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)
}
} }
} }
+41
View File
@@ -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')
}
}
+5
View File
@@ -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 -1
View File
@@ -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 => {
+4 -2
View File
@@ -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
-15
View File
@@ -1,15 +0,0 @@
{
"env": {
"commonjs": true,
"es2021": true,
"node": true
},
"extends": [
"standard"
],
"parserOptions": {
"ecmaVersion": 12
},
"rules": {
}
}
-28
View File
@@ -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>
+695 -1525
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -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"
} }
} }
+138
View File
@@ -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));
});
}
}
+36
View File
@@ -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
}
});
});
-120
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
export class GameSettings {
playerSize = 10;
playerSpeed = 5;
playerTurnSpeed = 10;
wallSize = 8;
wallUpdate = 3;
arenaSize = 1000;
constructor() { }
}
+6
View File
@@ -0,0 +1,6 @@
import WebSocket from 'ws';
export interface GameWebSocket extends WebSocket {
id: string;
name: string;
}
+80
View File
@@ -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)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export class Wall {
constructor (public x: number, public y: number) { }
}
-8
View File
@@ -1,8 +0,0 @@
module.exports = Object.freeze({
playerSize: 10,
playerSpeed: 5,
playerTurnSpeed: 10,
wallSize: 8,
wallUpdate: 5,
arenaSize: 1000
})
-74
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
class Wall {
constructor (x, y) {
this.x = x
this.y = y
}
}
module.exports = Wall
-33
View File
@@ -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
}
})
})
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "ES2020",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"strict": true
}
}