Compare commits
17 Commits
cacc530f35
...
a0f6e47f57
Author | SHA1 | Date |
---|---|---|
|
a0f6e47f57 | |
|
042d83e2c1 | |
|
23d99b2758 | |
|
fcc2bdd5f0 | |
|
55b16f6712 | |
|
eceefcc19c | |
|
b0a759dd26 | |
|
f6b64fc569 | |
|
e7221390f3 | |
|
70a9359c7f | |
|
ad935c0b56 | |
|
51b701663d | |
|
f60718e8fd | |
|
6700be5515 | |
|
81e31b2fb7 | |
|
3f6ecb84fc | |
|
79ae2c5d2b |
|
@ -1,5 +1,3 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
@ -8,17 +6,6 @@
|
|||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
@ -30,16 +17,15 @@ yarn-error.log*
|
|||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
# build outputs
|
||||
package-lock.json
|
||||
dist/
|
||||
target/
|
||||
|
||||
# common artifact extensions
|
||||
**/*.trace
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
|
||||
dist/
|
||||
target/
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
- name: Deploy gomoku project
|
||||
hosts: all
|
||||
become: yes
|
||||
vars:
|
||||
app_user: 'gomoku'
|
||||
app_home: '/home/{{ app_user }}'
|
||||
|
||||
tasks:
|
||||
- name: Ensure user exists
|
||||
user:
|
||||
name: '{{ app_user }}'
|
||||
shell: /bin/bash
|
||||
create_home: yes
|
||||
home: '{{ app_home }}'
|
||||
|
||||
- name: Create data directory
|
||||
file:
|
||||
path: '{{ app_home }}/public'
|
||||
state: directory
|
||||
owner: '{{ app_user }}'
|
||||
group: '{{ app_user }}'
|
||||
|
||||
- name: Copy binary to target location
|
||||
copy:
|
||||
src: ../target/gomoku
|
||||
dest: '{{ app_home }}/gomoku'
|
||||
owner: '{{ app_user }}'
|
||||
group: '{{ app_user }}'
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy data directory
|
||||
synchronize:
|
||||
src: ../public/
|
||||
dest: '{{ app_home }}/public/'
|
||||
recursive: yes
|
||||
owner: no
|
||||
group: no
|
||||
archive: yes
|
||||
delete: yes
|
||||
|
||||
- name: Ensure Caddy directory exists
|
||||
file:
|
||||
path: /etc/caddy/sites
|
||||
state: directory
|
||||
|
||||
- name: Copy Caddy configuration
|
||||
copy:
|
||||
src: gomoku.caddy
|
||||
dest: /etc/caddy/sites/gomoku.caddy
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
register: caddy_config_copy
|
||||
|
||||
- name: Restart Caddy service if config changed
|
||||
systemd:
|
||||
name: caddy
|
||||
state: restarted
|
||||
daemon_reload: yes
|
||||
when: caddy_config_copy.changed
|
||||
|
||||
- name: Ensure Supervisor directory exists
|
||||
file:
|
||||
path: /etc/supervisor/conf.d
|
||||
state: directory
|
||||
|
||||
- name: Copy Supervisor configuration
|
||||
copy:
|
||||
src: gomoku.supervisor
|
||||
dest: /etc/supervisor/conf.d/gomoku.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
|
||||
- name: Reload Supervisor
|
||||
supervisorctl:
|
||||
name: gomoku
|
||||
state: present
|
||||
|
||||
- name: Restart the service
|
||||
supervisorctl:
|
||||
name: gomoku
|
||||
state: restarted
|
|
@ -0,0 +1,3 @@
|
|||
gomoku.sepiatones.xyz {
|
||||
reverse_proxy localhost:3002
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
[program:gomoku]
|
||||
command=/home/gomoku/gomoku
|
||||
directory=/home/gomoku
|
||||
user=gomoku
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=3
|
||||
stderr_logfile=/var/log/supervisor/gomoku.err.log
|
||||
stdout_logfile=/var/log/supervisor/gomoku.out.log
|
||||
environment=PORT=3002
|
|
@ -0,0 +1,3 @@
|
|||
# It is expected that you have sepiatonesxyz defined in your .ssh/config
|
||||
[production]
|
||||
sepiatonesxyz ansible_host=sepiatonesxyz ansible_python_interpreter=/usr/bin/python3.11
|
|
@ -8,7 +8,7 @@ build:
|
|||
bun build --compile --minify --target bun --outfile ./target/gomoku ./src/index.ts
|
||||
|
||||
deploy: build
|
||||
rsync -avz target/gomoku sepiatonesxyz:~/gomoku
|
||||
ansible-playbook -i ansible/hosts.ini ansible/deploy.yml
|
||||
|
||||
test:
|
||||
bun test
|
||||
|
|
Before Width: | Height: | Size: 221 B After Width: | Height: | Size: 221 B |
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 220 B |
Before Width: | Height: | Size: 706 B After Width: | Height: | Size: 706 B |
|
@ -1,3 +0,0 @@
|
|||
<svg data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
Before Width: | Height: | Size: 381 B |
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 399 B |
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 241 B |
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 242 B |
|
@ -47,11 +47,14 @@
|
|||
<div id="button-box"></div>
|
||||
</div>
|
||||
|
||||
<script src="scripts/display-ws-connection.js"></script>
|
||||
<script src="scripts/make-ws-connection.js"></script>
|
||||
<script src="scripts/send-ws-messages.js"></script>
|
||||
<script src="scripts/profile-editor.js"></script>
|
||||
<script src="scripts/copy-game-link.js"></script>
|
||||
<script src="scripts/handle-redirects.js"></script>
|
||||
<script src="scripts/make-sounds.js"></script>
|
||||
<script src="scripts/make-animations.js"></script>
|
||||
<script src="scripts/client-info.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
document.addEventListener('htmx:wsAfterMessage', function (e) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(e.detail.message);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'client-info') {
|
||||
return;
|
||||
}
|
||||
console.log(`Message from server: ${msg.message}`);
|
||||
});
|
|
@ -0,0 +1,217 @@
|
|||
// Pre-loaded SVG content cache
|
||||
let blackStoneSvg = null;
|
||||
let whiteStoneSvg = null;
|
||||
|
||||
// Pre-load SVG content when the script loads
|
||||
async function preloadStoneSvgs() {
|
||||
try {
|
||||
// Fetch black stone SVG
|
||||
const blackResponse = await fetch('/black-stone.svg');
|
||||
if (blackResponse.ok) {
|
||||
blackStoneSvg = await blackResponse.text();
|
||||
} else {
|
||||
console.error('Failed to load black stone SVG:', blackResponse.status);
|
||||
}
|
||||
|
||||
// Fetch white stone SVG
|
||||
const whiteResponse = await fetch('/white-stone.svg');
|
||||
if (whiteResponse.ok) {
|
||||
whiteStoneSvg = await whiteResponse.text();
|
||||
} else {
|
||||
console.error('Failed to load white stone SVG:', whiteResponse.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error pre-loading stone SVGs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-load the SVGs immediately
|
||||
preloadStoneSvgs();
|
||||
|
||||
// Create bouncing stone animation without anime.js
|
||||
function createBouncingStonesAnimation(whiteCount = 20, blackCount = 20) {
|
||||
const gameBoard = document.getElementById('game-board');
|
||||
if (!gameBoard) return;
|
||||
|
||||
// Clear any existing animation elements
|
||||
const existingAnimations = gameBoard.querySelectorAll('.animation-stone');
|
||||
existingAnimations.forEach((stone) => stone.remove());
|
||||
|
||||
const boardRect = gameBoard.getBoundingClientRect();
|
||||
const stones = [];
|
||||
|
||||
// Create stone elements with pre-loaded SVG content
|
||||
function createStones() {
|
||||
for (let i = 0; i < whiteCount + blackCount; i++) {
|
||||
const stone = document.createElement('div');
|
||||
stone.className = 'animation-stone';
|
||||
stone.style.position = 'absolute';
|
||||
stone.style.width = '30px';
|
||||
stone.style.height = '30px';
|
||||
stone.style.pointerEvents = 'none';
|
||||
stone.style.zIndex = '1000';
|
||||
stone.style.opacity = '1';
|
||||
|
||||
// Set the pre-loaded SVG content as innerHTML
|
||||
stone.innerHTML = i < whiteCount ? whiteStoneSvg : blackStoneSvg;
|
||||
|
||||
// Make sure the SVG inside fills the container
|
||||
const svg = stone.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.width = '100%';
|
||||
svg.style.height = '100%';
|
||||
}
|
||||
|
||||
// Random starting position within the board
|
||||
const startX = Math.random() * (boardRect.width - 30);
|
||||
const startY = Math.random() * (boardRect.height - 30);
|
||||
|
||||
stone.style.left = startX + 'px';
|
||||
stone.style.top = startY + 'px';
|
||||
|
||||
gameBoard.appendChild(stone);
|
||||
stones.push({
|
||||
element: stone,
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx: (Math.random() - 0.5) * 8, // Random velocity between -4 and 4
|
||||
vy: (Math.random() - 0.5) * 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create the stones (no need to wait since we're using pre-loaded content)
|
||||
createStones();
|
||||
|
||||
// Animation state
|
||||
let animationId;
|
||||
let startTime;
|
||||
let isPlaying = false;
|
||||
|
||||
// Animation function
|
||||
function animateStones() {
|
||||
stones.forEach((stone) => {
|
||||
// Update position
|
||||
stone.x += stone.vx;
|
||||
stone.y += stone.vy;
|
||||
|
||||
// Bounce off edges
|
||||
if (stone.x <= 0 || stone.x >= boardRect.width - 30) {
|
||||
stone.vx = -stone.vx;
|
||||
stone.x = Math.max(0, Math.min(boardRect.width - 30, stone.x));
|
||||
}
|
||||
if (stone.y <= 0 || stone.y >= boardRect.height - 30) {
|
||||
stone.vy = -stone.vy;
|
||||
stone.y = Math.max(0, Math.min(boardRect.height - 30, stone.y));
|
||||
}
|
||||
|
||||
// Apply position
|
||||
stone.element.style.left = stone.x + 'px';
|
||||
stone.element.style.top = stone.y + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Fade out function
|
||||
function fadeOut() {
|
||||
const animationStones = document.querySelectorAll('.animation-stone');
|
||||
const fadeStartTime = performance.now();
|
||||
const fadeDuration = 500;
|
||||
|
||||
function fade(currentTime) {
|
||||
const elapsed = currentTime - fadeStartTime;
|
||||
const progress = Math.min(elapsed / fadeDuration, 1);
|
||||
const opacity = 1 - progress;
|
||||
|
||||
animationStones.forEach((stone) => {
|
||||
stone.style.opacity = opacity;
|
||||
});
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(fade);
|
||||
} else {
|
||||
// Remove stones after fade is complete
|
||||
animationStones.forEach((stone) => stone.remove());
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(fade);
|
||||
}
|
||||
|
||||
// Main animation loop
|
||||
function animate(currentTime) {
|
||||
if (!startTime) startTime = currentTime;
|
||||
const elapsed = currentTime - startTime;
|
||||
|
||||
// Wait 15s to fade away
|
||||
if (elapsed < 15000) {
|
||||
animateStones();
|
||||
animationId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Animation complete - start fade out
|
||||
fadeOut();
|
||||
}
|
||||
}
|
||||
|
||||
// Animation control object
|
||||
const animationControl = {
|
||||
play: function () {
|
||||
if (!isPlaying) {
|
||||
isPlaying = true;
|
||||
startTime = null; // Reset start time
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
},
|
||||
pause: function () {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
isPlaying = false;
|
||||
}
|
||||
},
|
||||
stop: function () {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
isPlaying = false;
|
||||
}
|
||||
// Remove all stones immediately
|
||||
const animationStones = document.querySelectorAll('.animation-stone');
|
||||
animationStones.forEach((stone) => stone.remove());
|
||||
},
|
||||
};
|
||||
|
||||
return animationControl;
|
||||
}
|
||||
|
||||
const animations = {};
|
||||
|
||||
// We use functions so that we have time to pre-load the svgs before trying to create the animations
|
||||
// which isn't actually ideal because what if the animation plays right away, but, it works!
|
||||
animations['black-victory'] = () => {
|
||||
return createBouncingStonesAnimation(0, 40);
|
||||
};
|
||||
|
||||
animations['white-victory'] = () => {
|
||||
return createBouncingStonesAnimation(40, 0);
|
||||
};
|
||||
|
||||
animations['draw'] = () => {
|
||||
return createBouncingStonesAnimation(20, 20);
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:wsAfterMessage', async function (e) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(e.detail.message);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'animation') {
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFn = animations[msg.animation];
|
||||
if (animationFn) {
|
||||
animationFn().play();
|
||||
} else {
|
||||
console.error('Unknown animation:', msg.animation);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
const sounds = {};
|
||||
sounds['victory'] = new Audio('/sounds/victory.ogg');
|
||||
sounds['defeat'] = new Audio('/sounds/defeat.ogg');
|
||||
sounds['draw'] = new Audio('/sounds/draw.ogg');
|
||||
sounds['move'] = new Audio('/sounds/move.ogg');
|
||||
|
||||
Object.values(sounds).forEach((sound) => {
|
||||
sound.volume = 0.25;
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:wsAfterMessage', function (e) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(e.detail.message);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== 'sound') {
|
||||
return;
|
||||
}
|
||||
sounds[msg.sound].play();
|
||||
});
|
8
public/scripts/display-ws-connection.js → public/scripts/make-ws-connection.js
Normal file → Executable file
|
@ -8,7 +8,13 @@ const gameId = gameIdMeta.content;
|
|||
const playerId = playerIdMeta.content;
|
||||
|
||||
// Dynamically construct WebSocket URL
|
||||
const wsUrl = `ws://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`;
|
||||
let wsProtocol;
|
||||
if (window.location.protocol === 'https:') {
|
||||
wsProtocol = 'wss';
|
||||
} else {
|
||||
wsProtocol = 'ws';
|
||||
}
|
||||
const wsUrl = `${wsProtocol}://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`;
|
||||
|
||||
// Get the game container element
|
||||
const gameContainer = document.getElementById('ws-container');
|
|
@ -1,3 +1,5 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Cabin:ital,wght@0,400..700;1,400..700&display=swap');
|
||||
|
||||
:root {
|
||||
/* PRIMARY BRAND COLORS */
|
||||
--color-primary: #c12675; /* Main brand color - primary buttons, links, highlights */
|
||||
|
@ -76,8 +78,11 @@
|
|||
--color-bg-accent: #fdf2f8; /* Very subtle pink background for special sections */
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: Cabin, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@ -149,48 +154,20 @@ body {
|
|||
width: 24px;
|
||||
height: 24px;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stone-black-heart::before,
|
||||
.stone-black-heart::after,
|
||||
.stone-white-heart::before,
|
||||
.stone-white-heart::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
border-radius: 50% 50% 0 0;
|
||||
border: 1px solid var(--color-neutral-900);
|
||||
box-sizing: border-box;
|
||||
transform: rotate(-45deg);
|
||||
transform-origin: 0 100%;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
.stone-black-heart {
|
||||
fill: var(--color-primary);
|
||||
stroke: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.last-move.stone-white-heart::after,
|
||||
.last-move.stone-black-heart::after,
|
||||
.last-move.stone-white-heart::before,
|
||||
.last-move.stone-black-heart::before {
|
||||
border: 2px solid var(--color-info) !important;
|
||||
.stone-white-heart {
|
||||
stroke: var(--color-neutral-900);
|
||||
fill: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.stone-black-heart::after,
|
||||
.stone-white-heart::after {
|
||||
left: 0;
|
||||
transform: rotate(45deg);
|
||||
transform-origin: 100% 100%;
|
||||
}
|
||||
|
||||
.stone-black-heart::before,
|
||||
.stone-black-heart::after {
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.stone-white-heart::before,
|
||||
.stone-white-heart::after {
|
||||
background-color: var(--color-on-primary);
|
||||
.last-move {
|
||||
stroke: var(--color-info) !important;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
|
@ -236,7 +213,8 @@ body {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: opacity 0.3s ease;
|
||||
padding: 8px 15px;
|
||||
padding: 15px 8px;
|
||||
margin: 0px 6px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
|
@ -248,18 +226,22 @@ body {
|
|||
|
||||
#button-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#button-box-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
#resign-button {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
#resign-button:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
@ -268,7 +250,9 @@ body {
|
|||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
#copy-link-button:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
#copy-link-button.copied-state {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
@ -277,7 +261,6 @@ body {
|
|||
background-color: var(--color-info);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
#takeback-button:hover {
|
||||
background-color: var(--color-info-light);
|
||||
}
|
||||
|
@ -286,16 +269,22 @@ body {
|
|||
background-color: var(--color-secondary);
|
||||
color: var(--color-on-secondary);
|
||||
}
|
||||
|
||||
#draw-button:hover {
|
||||
background-color: var(--color-secondary-light);
|
||||
}
|
||||
|
||||
#rematch-button {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
#rematch-button:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.accept-button {
|
||||
background-color: var(--color-success);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.accept-button:hover {
|
||||
background-color: var(--color-success-light);
|
||||
}
|
||||
|
@ -304,7 +293,6 @@ body {
|
|||
background-color: var(--color-error);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.decline-button:hover {
|
||||
background-color: var(--color-error-light);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
import { Elysia, t } from 'elysia';
|
||||
import { html } from '@elysiajs/html';
|
||||
import { staticPlugin } from '@elysiajs/static';
|
||||
import { cookie } from '@elysiajs/cookie';
|
||||
import { WebSocketHandler } from './web-socket-handler';
|
||||
import { ElysiaWS } from 'elysia/dist/ws';
|
||||
import { createStoneSvg } from './view/board-renderer';
|
||||
|
||||
const wsHandler = new WebSocketHandler();
|
||||
export type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
staticPlugin({
|
||||
assets: './public',
|
||||
prefix: '/',
|
||||
}),
|
||||
)
|
||||
.use(cookie())
|
||||
.use(html())
|
||||
.ws('/ws', {
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
playerId: t.String(),
|
||||
}),
|
||||
open(ws) {
|
||||
wsHandler.handleConnection(ws);
|
||||
},
|
||||
message(ws, message) {
|
||||
wsHandler.handleMessage(ws, message);
|
||||
},
|
||||
close(ws) {
|
||||
wsHandler.handleDisconnect(ws);
|
||||
},
|
||||
})
|
||||
.get('/', async ({ query, cookie, request: _request }) => {
|
||||
let playerId: string;
|
||||
const existingPlayerId = cookie.playerId?.value;
|
||||
if (existingPlayerId) {
|
||||
playerId = existingPlayerId;
|
||||
console.log(`Using existing playerId from cookie: ${playerId}`);
|
||||
} else {
|
||||
playerId = `player-${Math.random().toString(36).substring(2, 9)}`;
|
||||
cookie.playerId.set({
|
||||
value: playerId,
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
console.log(`Generated new playerId and set cookie: ${playerId}`);
|
||||
}
|
||||
|
||||
let gameId = query.gameId as string | undefined;
|
||||
let gameIdInitialized = false;
|
||||
if (gameId) {
|
||||
if (!wsHandler.hasGame(gameId)) {
|
||||
wsHandler.createGame(gameId);
|
||||
console.log(`Created new game with provided ID: ${gameId}`);
|
||||
}
|
||||
} else {
|
||||
gameId = wsHandler.createGame();
|
||||
gameIdInitialized = true;
|
||||
console.log(`Created new game without specific ID: ${gameId}`);
|
||||
}
|
||||
|
||||
if (gameIdInitialized) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `/?gameId=${gameId}` },
|
||||
});
|
||||
}
|
||||
|
||||
const displayName = wsHandler.getPlayerName(playerId);
|
||||
|
||||
const htmlTemplate = await Bun.file('./public/index.html').text();
|
||||
let finalHtml = htmlTemplate
|
||||
.replace(
|
||||
'<meta name="gameId" content="" />',
|
||||
`<meta name="gameId" content="${gameId}" />`,
|
||||
)
|
||||
.replace(
|
||||
'<meta name="playerId" content="" />',
|
||||
`<meta name="playerId" content="${playerId}" />`,
|
||||
)
|
||||
.replace(
|
||||
'<meta name="displayName" content="" />',
|
||||
`<meta name="displayName" content="${displayName}" />`,
|
||||
);
|
||||
|
||||
return new Response(finalHtml, {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.get('/black-stone.svg', () => {
|
||||
const stoneSvg = createStoneSvg('black', false);
|
||||
return new Response(String(stoneSvg), {
|
||||
headers: { 'Content-Type': 'image/svg+xml' },
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.get('/white-stone.svg', () => {
|
||||
const stoneSvg = createStoneSvg('white', false);
|
||||
return new Response(String(stoneSvg), {
|
||||
headers: { 'Content-Type': 'image/svg+xml' },
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${port}`);
|
||||
});
|
|
@ -0,0 +1,140 @@
|
|||
import { WS } from '.';
|
||||
import { GomokuGame, PlayerColor } from './game/game-instance';
|
||||
import { handleDrawMessage, DrawMessage } from './messages/draw';
|
||||
import { handleMakeMove, MakeMoveMessage } from './messages/make-move';
|
||||
import { Message } from './messages/messages';
|
||||
import { handleRematchMessage, RematchMessage } from './messages/rematch';
|
||||
import { handleResignation, ResignationMessage } from './messages/resign';
|
||||
import { handleTakebackMessage, TakebackMessage } from './messages/takeback';
|
||||
import {
|
||||
handleUpdateDisplayName,
|
||||
UpdateDisplayNameMessage,
|
||||
} from './messages/update-display-name';
|
||||
import { PlayerId, PlayerConnection } from './player-connection';
|
||||
import { broadcastBoard } from './view/board-renderer';
|
||||
import { broadcastButtons } from './view/button-renderer';
|
||||
import { broadcastTitle } from './view/title-renderer';
|
||||
import { WebSocketHandler } from './web-socket-handler';
|
||||
|
||||
export type GameId = string;
|
||||
|
||||
export class GameServer {
|
||||
id: GameId;
|
||||
gomoku: GomokuGame;
|
||||
connections: Map<PlayerId, PlayerConnection>;
|
||||
blackPlayerId?: PlayerId;
|
||||
whitePlayerId?: PlayerId;
|
||||
takebackRequesterId: PlayerId | null = null;
|
||||
drawRequesterId: PlayerId | null = null;
|
||||
rematchRequesterId: PlayerId | null = null;
|
||||
|
||||
constructor(
|
||||
id: GameId,
|
||||
public webSocketHandler: WebSocketHandler,
|
||||
) {
|
||||
this.id = id;
|
||||
this.gomoku = new GomokuGame();
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
public handleConnection(ws: WS) {
|
||||
const { playerId } = ws.data.query;
|
||||
if (this.connections.has(playerId)) {
|
||||
const existingConn = this.connections.get(playerId)!;
|
||||
existingConn.ws = ws; // Update with new WebSocket
|
||||
console.log(
|
||||
`Updated connection for player ${playerId} in game ${this.id}, replacing old WS.`,
|
||||
);
|
||||
} else {
|
||||
const playerName = this.webSocketHandler.getPlayerName(playerId); // Retrieve name or use ID
|
||||
const conn = new PlayerConnection(playerId, playerName, ws);
|
||||
this.connections.set(playerId, conn);
|
||||
console.log(
|
||||
`Created new connection with player ${conn.id} in game ${this.id}`,
|
||||
);
|
||||
|
||||
if (!this.blackPlayerId) {
|
||||
this.blackPlayerId = conn.id;
|
||||
} else if (!this.whitePlayerId) {
|
||||
this.whitePlayerId = conn.id;
|
||||
}
|
||||
}
|
||||
if (this.whitePlayerId && this.blackPlayerId) {
|
||||
this.gomoku.status = 'playing';
|
||||
}
|
||||
|
||||
broadcastBoard(this);
|
||||
broadcastButtons(this);
|
||||
broadcastTitle(this);
|
||||
}
|
||||
|
||||
public handleDisconnect(ws: WS) {
|
||||
const { playerId } = ws.data.query;
|
||||
this.connections.delete(playerId);
|
||||
broadcastTitle(this);
|
||||
}
|
||||
|
||||
public handleMessage(ws: WS, message: Message): void {
|
||||
const conn = this.connections.get(ws.data.query.playerId);
|
||||
if (!conn) {
|
||||
console.error(
|
||||
`Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Handling ${message.type} message in game ${this.id} from player ${conn.id}: ${JSON.stringify(message)}`,
|
||||
);
|
||||
switch (message.type) {
|
||||
case 'make_move': {
|
||||
handleMakeMove(this, conn, message as MakeMoveMessage);
|
||||
break;
|
||||
}
|
||||
case 'resign': {
|
||||
handleResignation(this, conn, message as ResignationMessage);
|
||||
break;
|
||||
}
|
||||
case 'takeback': {
|
||||
handleTakebackMessage(this, conn, message as TakebackMessage);
|
||||
break;
|
||||
}
|
||||
case 'draw': {
|
||||
handleDrawMessage(this, conn, message as DrawMessage);
|
||||
break;
|
||||
}
|
||||
case 'rematch': {
|
||||
handleRematchMessage(this, conn, message as RematchMessage);
|
||||
break;
|
||||
}
|
||||
case 'update_display_name': {
|
||||
handleUpdateDisplayName(
|
||||
this,
|
||||
conn,
|
||||
message as UpdateDisplayNameMessage,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getPlayerColor(conn: PlayerConnection): PlayerColor | undefined {
|
||||
if (this.blackPlayerId === conn.id) {
|
||||
return 'black';
|
||||
} else if (this.whitePlayerId === conn.id) {
|
||||
return 'white';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public getPlayerFromColor(color: PlayerColor) {
|
||||
if (color === 'white' && this.whitePlayerId) {
|
||||
return this.connections.get(this.whitePlayerId);
|
||||
} else if (color === 'black' && this.blackPlayerId) {
|
||||
return this.connections.get(this.blackPlayerId);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,134 +5,48 @@ describe('GameInstance', () => {
|
|||
test('should initialize with correct default state', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
expect(game.id).toBeDefined();
|
||||
expect(game.board.length).toBe(15);
|
||||
expect(game.board[0].length).toBe(15);
|
||||
expect(game.currentPlayer).toBeNull();
|
||||
expect(game.currentPlayerColor).toBe('black');
|
||||
expect(game.status).toBe('waiting');
|
||||
expect(game.winner).toBeNull();
|
||||
expect(game.players).toEqual({});
|
||||
});
|
||||
|
||||
test('should add players correctly', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
// First player joins as black
|
||||
const joined1 = game.addPlayer(player1);
|
||||
expect(joined1).toBe(true);
|
||||
|
||||
// Second player joins as white
|
||||
const joined2 = game.addPlayer(player2);
|
||||
expect(joined2).toBe(true);
|
||||
|
||||
// Game should now be in playing state
|
||||
expect(game.status).toBe('playing');
|
||||
expect(game.currentPlayer).toBe('black');
|
||||
expect(game.players.black).toBe(player1);
|
||||
expect(game.players.white).toBe(player2);
|
||||
});
|
||||
|
||||
test('should prevent more than two players from joining', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
const player3 = 'player3-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Third player tries to join (should fail)
|
||||
const joined = game.addPlayer(player3);
|
||||
expect(joined).toBe(false);
|
||||
|
||||
// Players object should remain unchanged, only two players should be present
|
||||
expect(game.players.black).toBe(player1);
|
||||
expect(game.players.white).toBe(player2);
|
||||
expect(Object.values(game.players).length).toBe(2);
|
||||
expect(game.winnerColor).toBeNull();
|
||||
});
|
||||
|
||||
test('should validate moves correctly', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Player black makes first move
|
||||
const move1 = game.makeMove(player1, 7, 7);
|
||||
const move1 = game.makeMove('black', 7, 7);
|
||||
expect(move1.success).toBe(true);
|
||||
|
||||
// Same player tries to move again (should fail)
|
||||
const move2 = game.makeMove(player1, 7, 8);
|
||||
const move2 = game.makeMove('black', 7, 8);
|
||||
expect(move2.success).toBe(false);
|
||||
|
||||
// White player makes a move
|
||||
const move3 = game.makeMove(player2, 7, 8);
|
||||
const move3 = game.makeMove('white', 7, 8);
|
||||
expect(move3.success).toBe(true);
|
||||
|
||||
// Try to place on occupied cell (should fail)
|
||||
const move4 = game.makeMove(player2, 7, 7);
|
||||
const move4 = game.makeMove('white', 7, 7);
|
||||
expect(move4.success).toBe(false);
|
||||
|
||||
// Try to place out of bounds (should fail)
|
||||
const move5 = game.makeMove(player2, 15, 15);
|
||||
const move5 = game.makeMove('white', 15, 15);
|
||||
expect(move5.success).toBe(false);
|
||||
});
|
||||
|
||||
test('should detect win conditions', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Create a horizontal win for black
|
||||
for (let col = 0; col < 5; col++) {
|
||||
game.makeMove(player1, 7, col);
|
||||
game.makeMove('black', 7, col);
|
||||
// Switch to other player for next move
|
||||
if (col < 4) game.makeMove(player2, 8, col);
|
||||
if (col < 4) game.makeMove('white', 8, col);
|
||||
}
|
||||
|
||||
expect(game.winner).toBe('black');
|
||||
expect(game.status).toBe('finished');
|
||||
});
|
||||
|
||||
test('should detect draw condition', () => {
|
||||
const game = new GomokuGame();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Create a pattern that doesn't result in a win but fills the board
|
||||
// We'll use a simple alternating pattern
|
||||
for (let row = 0; row < 15; row++) {
|
||||
for (let col = 0; col < 15; col++) {
|
||||
const currentPlayer = game.currentPlayer!;
|
||||
const playerId = game.players[currentPlayer]!;
|
||||
|
||||
// Make move
|
||||
const result = game.makeMove(playerId, row, col);
|
||||
|
||||
// If we can't make a move, it means someone won already
|
||||
if (!result.success) {
|
||||
expect(game.winner).not.toBeNull();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(game.winner).toBe('draw');
|
||||
expect(game.winnerColor).toBe('black');
|
||||
expect(game.status).toBe('finished');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,7 +10,6 @@ export class GomokuGame {
|
|||
public history: { row: number; col: number }[];
|
||||
|
||||
private readonly boardSize = 15;
|
||||
private moveCount = 0;
|
||||
|
||||
constructor() {
|
||||
this.board = Array.from({ length: this.boardSize }, () =>
|
||||
|
@ -44,7 +43,6 @@ export class GomokuGame {
|
|||
|
||||
// Make the move
|
||||
this.board[row][col] = playerColor;
|
||||
this.moveCount++;
|
||||
|
||||
// If this was the first move, declare the game to have begun
|
||||
if (this.status === 'waiting') {
|
||||
|
@ -63,7 +61,7 @@ export class GomokuGame {
|
|||
}
|
||||
|
||||
// Check for draw condition
|
||||
if (this.moveCount === this.boardSize * this.boardSize) {
|
||||
if (this.history.length >= this.boardSize * this.boardSize) {
|
||||
this.winnerColor = 'draw';
|
||||
this.status = 'finished';
|
||||
this.currentPlayerColor = null;
|
||||
|
@ -90,7 +88,6 @@ export class GomokuGame {
|
|||
const lastMove = this.history.pop();
|
||||
if (lastMove) {
|
||||
this.board[lastMove.row][lastMove.col] = null;
|
||||
this.moveCount--;
|
||||
this.currentPlayerColor =
|
||||
this.currentPlayerColor === 'black' ? 'white' : 'black';
|
||||
if (this.status === 'finished') {
|
||||
|
|
|
@ -3,9 +3,11 @@ import { html } from '@elysiajs/html';
|
|||
import { staticPlugin } from '@elysiajs/static';
|
||||
import { cookie } from '@elysiajs/cookie';
|
||||
import { WebSocketHandler } from './web-socket-handler';
|
||||
import { GomokuGame } from './game/game-instance';
|
||||
import { ElysiaWS } from 'elysia/dist/ws';
|
||||
import { createStoneSvg } from './view/board-renderer';
|
||||
|
||||
const wsHandler = new WebSocketHandler();
|
||||
export type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
|
@ -49,19 +51,18 @@ const app = new Elysia()
|
|||
}
|
||||
|
||||
let gameId = query.gameId as string | undefined;
|
||||
let gameIdInitialized = false;
|
||||
if (gameId) {
|
||||
if (!wsHandler.hasGame(gameId)) {
|
||||
wsHandler.createGame(gameId);
|
||||
console.log(`Created new game with provided ID: ${gameId}`);
|
||||
} else {
|
||||
console.log(`Player ${playerId} visited existing game ${gameId}`);
|
||||
}
|
||||
} else {
|
||||
gameId = wsHandler.createGame();
|
||||
gameIdInitialized = true;
|
||||
console.log(`Created new game without specific ID: ${gameId}`);
|
||||
}
|
||||
|
||||
if (gameIdInitialized) {
|
||||
console.log(
|
||||
`Player ${playerId} came without a gameId. Redirecting them to new game ${gameId}.`,
|
||||
);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `/?gameId=${gameId}` },
|
||||
|
@ -89,6 +90,20 @@ const app = new Elysia()
|
|||
headers: { 'Content-Type': 'text/html' },
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.get('/black-stone.svg', () => {
|
||||
const stoneSvg = createStoneSvg('black', false);
|
||||
return new Response(String(stoneSvg), {
|
||||
headers: { 'Content-Type': 'image/svg+xml' },
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.get('/white-stone.svg', () => {
|
||||
const stoneSvg = createStoneSvg('white', false);
|
||||
return new Response(String(stoneSvg), {
|
||||
headers: { 'Content-Type': 'image/svg+xml' },
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
export type ActionType = 'request' | 'accept' | 'decline' | 'cancel';
|
||||
|
||||
export interface Message {
|
||||
type:
|
||||
| 'make_move'
|
||||
| 'resign'
|
||||
| 'takeback'
|
||||
| 'draw'
|
||||
| 'rematch'
|
||||
| 'redirect_to_game'
|
||||
| 'update_display_name';
|
||||
}
|
||||
|
||||
export interface UpdateDisplayNameMessage extends Message {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface MakeMoveMessage extends Message {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export interface ResignationMessage extends Message {}
|
||||
|
||||
export interface TakebackMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export interface DrawMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export interface RematchMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export interface RedirectToGameMessage extends Message {
|
||||
gameId: string;
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastBoard } from '../view/board-renderer';
|
||||
import { broadcastButtons } from '../view/button-renderer';
|
||||
import { broadcastSound } from '../view/sound-renderer';
|
||||
import { broadcastAnimation } from '../view/animation-renderer';
|
||||
import { broadcastTitle } from '../view/title-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { ActionType, Message } from './messages';
|
||||
|
||||
export interface DrawMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export function handleDrawMessage(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: DrawMessage,
|
||||
): void {
|
||||
if (server.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
handleRequestDraw(server, conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!server.drawRequesterId) {
|
||||
conn.sendMessage('error', 'No draw has been requested.');
|
||||
return;
|
||||
}
|
||||
handleAcceptDraw(server);
|
||||
break;
|
||||
case 'decline':
|
||||
if (!server.drawRequesterId) {
|
||||
conn.sendMessage('error', 'No draw has been requested.');
|
||||
return;
|
||||
}
|
||||
handleDeclineDraw(server);
|
||||
break;
|
||||
case 'cancel':
|
||||
if (server.drawRequesterId !== conn.id) {
|
||||
conn.sendMessage('error', 'You are not the one who requested a draw.');
|
||||
return;
|
||||
}
|
||||
handleCancelDrawRequest(server);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequestDraw(server: GameServer, conn: PlayerConnection): void {
|
||||
if (server.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'A takeback has already been requested.');
|
||||
return;
|
||||
}
|
||||
|
||||
server.drawRequesterId = conn.id;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleAcceptDraw(server: GameServer): void {
|
||||
server.gomoku.declareDraw();
|
||||
server.drawRequesterId = null;
|
||||
broadcastBoard(server);
|
||||
broadcastButtons(server);
|
||||
broadcastTitle(server);
|
||||
broadcastSound(server, 'draw');
|
||||
broadcastAnimation(server, 'draw');
|
||||
}
|
||||
|
||||
function handleDeclineDraw(server: GameServer): void {
|
||||
server.drawRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleCancelDrawRequest(server: GameServer): void {
|
||||
server.drawRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastBoard } from '../view/board-renderer';
|
||||
import { broadcastButtons } from '../view/button-renderer';
|
||||
import { broadcastSound, broadcastSoundToPlayer } from '../view/sound-renderer';
|
||||
import { broadcastAnimation } from '../view/animation-renderer';
|
||||
import { broadcastTitle } from '../view/title-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { Message } from './messages';
|
||||
|
||||
export interface MakeMoveMessage extends Message {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export function handleMakeMove(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: MakeMoveMessage,
|
||||
): void {
|
||||
const { row, col } = message;
|
||||
|
||||
if (server.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You cannot play a move while the game is not ongoing!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var playerColor;
|
||||
if (server.blackPlayerId === conn.id) {
|
||||
playerColor = 'black';
|
||||
} else if (server.whitePlayerId == conn.id) {
|
||||
playerColor = 'white';
|
||||
} else {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not a player in this game, you cannot make a move!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (server.gomoku.currentPlayerColor !== playerColor) {
|
||||
conn.sendMessage('error', "It's not your turn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.takebackRequesterId || server.drawRequesterId) {
|
||||
server.takebackRequesterId = null;
|
||||
server.drawRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
const stateBeforeMove = server.gomoku.status;
|
||||
const result = server.gomoku.makeMove(playerColor, row, col);
|
||||
if (result.success) {
|
||||
broadcastBoard(server);
|
||||
broadcastTitle(server);
|
||||
// We only need to re-send buttons when the game state changes
|
||||
if (stateBeforeMove != server.gomoku.status) {
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
// Broadcast sounds
|
||||
if (server.gomoku.status === 'playing') {
|
||||
broadcastSound(server, 'move');
|
||||
} else {
|
||||
const whiteConn = server.whitePlayerId
|
||||
? server.connections.get(server.whitePlayerId)
|
||||
: null;
|
||||
const blackConn = server.blackPlayerId
|
||||
? server.connections.get(server.blackPlayerId)
|
||||
: null;
|
||||
switch (server.gomoku.winnerColor) {
|
||||
case 'draw':
|
||||
broadcastSound(server, 'draw');
|
||||
broadcastAnimation(server, 'draw');
|
||||
break;
|
||||
case 'white':
|
||||
if (whiteConn) {
|
||||
broadcastSoundToPlayer(whiteConn, 'victory');
|
||||
}
|
||||
if (blackConn) {
|
||||
broadcastSoundToPlayer(blackConn, 'defeat');
|
||||
}
|
||||
broadcastAnimation(server, 'white-victory');
|
||||
break;
|
||||
case 'black':
|
||||
if (whiteConn) {
|
||||
broadcastSoundToPlayer(whiteConn, 'defeat');
|
||||
}
|
||||
if (blackConn) {
|
||||
broadcastSoundToPlayer(blackConn, 'victory');
|
||||
}
|
||||
broadcastAnimation(server, 'black-victory');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Move made in game ${server.id} by ${conn.id}: (${row}, ${col})`,
|
||||
);
|
||||
} else {
|
||||
conn.sendMessage('error', result.error!);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface Message {
|
||||
type: string;
|
||||
}
|
||||
export type ActionType = 'request' | 'accept' | 'decline' | 'cancel';
|
|
@ -0,0 +1,84 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastButtons } from '../view/button-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { ActionType, Message } from './messages';
|
||||
|
||||
export interface RematchMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export function handleRematchMessage(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: RematchMessage,
|
||||
): void {
|
||||
if (server.gomoku.status !== 'finished') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in a finished game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
handleRequestRematch(server, conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!server.rematchRequesterId) {
|
||||
conn.sendMessage('error', 'No rematch has been requested.');
|
||||
return;
|
||||
}
|
||||
handleAcceptRematch(server);
|
||||
break;
|
||||
case 'decline':
|
||||
if (!server.rematchRequesterId) {
|
||||
conn.sendMessage('error', 'No rematch has been requested.');
|
||||
return;
|
||||
}
|
||||
handleDeclineRematch(server);
|
||||
break;
|
||||
case 'cancel':
|
||||
if (server.rematchRequesterId !== conn.id) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not the one who requested a rematch.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleCancelRematchRequest(server);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequestRematch(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
): void {
|
||||
server.rematchRequesterId = conn.id;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleAcceptRematch(server: GameServer): void {
|
||||
const newGameId = server.webSocketHandler.createGame(
|
||||
undefined,
|
||||
server.whitePlayerId,
|
||||
server.blackPlayerId,
|
||||
);
|
||||
const redirectMessage = {
|
||||
type: 'redirect_to_game',
|
||||
gameId: newGameId,
|
||||
};
|
||||
server.connections.forEach((c) => {
|
||||
c.ws.send(redirectMessage);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeclineRematch(server: GameServer): void {
|
||||
server.rematchRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleCancelRematchRequest(server: GameServer): void {
|
||||
server.rematchRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastBoard } from '../view/board-renderer';
|
||||
import { broadcastButtons } from '../view/button-renderer';
|
||||
import { broadcastSoundToPlayer } from '../view/sound-renderer';
|
||||
import { broadcastAnimation } from '../view/animation-renderer';
|
||||
import { broadcastTitle } from '../view/title-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { Message } from './messages';
|
||||
|
||||
export interface ResignationMessage extends Message {}
|
||||
|
||||
export function handleResignation(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: ResignationMessage,
|
||||
): void {
|
||||
console.log(
|
||||
`Handling resign message in game ${server.id} from player ${conn.id}: ${{ message }}`,
|
||||
);
|
||||
|
||||
if (server.gomoku.status !== 'playing') {
|
||||
conn.sendMessage('error', 'You can only resign from an active game.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resigningPlayerColor = server.getPlayerColor(conn);
|
||||
if (!resigningPlayerColor) {
|
||||
conn.sendMessage('error', 'You are not a player in server game.');
|
||||
return;
|
||||
}
|
||||
|
||||
server.gomoku.resign(resigningPlayerColor);
|
||||
broadcastBoard(server);
|
||||
broadcastTitle(server);
|
||||
broadcastButtons(server);
|
||||
broadcastSoundToPlayer(conn, 'defeat');
|
||||
const otherPlayerId =
|
||||
resigningPlayerColor === 'white'
|
||||
? server.blackPlayerId
|
||||
: server.whitePlayerId;
|
||||
if (otherPlayerId) {
|
||||
const otherPlayer = server.connections.get(otherPlayerId);
|
||||
if (otherPlayer) {
|
||||
broadcastSoundToPlayer(otherPlayer, 'victory');
|
||||
// Broadcast animation for the winning player's color
|
||||
if (resigningPlayerColor === 'white') {
|
||||
broadcastAnimation(server, 'black-victory');
|
||||
} else {
|
||||
broadcastAnimation(server, 'white-victory');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Player ${conn.id} resigned from game ${server.id}`);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastBoard } from '../view/board-renderer';
|
||||
import { broadcastButtons } from '../view/button-renderer';
|
||||
import { broadcastTitle } from '../view/title-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { ActionType, Message } from './messages';
|
||||
|
||||
export interface TakebackMessage extends Message {
|
||||
action: ActionType;
|
||||
}
|
||||
|
||||
export function handleTakebackMessage(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: TakebackMessage,
|
||||
): void {
|
||||
if (server.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
handleRequestTakeback(server, conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!server.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
handleAcceptTakeback(server);
|
||||
break;
|
||||
case 'decline':
|
||||
if (!server.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
handleDeclineTakeback(server);
|
||||
break;
|
||||
case 'cancel':
|
||||
if (server.takebackRequesterId !== conn.id) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not the one who requested a takeback.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleCancelTakebackRequest(server);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequestTakeback(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
): void {
|
||||
if (server.gomoku.history.length === 0) {
|
||||
conn.sendMessage('error', 'There are no moves to take back.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.drawRequesterId) {
|
||||
conn.sendMessage('error', 'A draw has already been requested.');
|
||||
return;
|
||||
}
|
||||
server.takebackRequesterId = conn.id;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleAcceptTakeback(server: GameServer): void {
|
||||
server.gomoku.undoMove();
|
||||
server.takebackRequesterId = null;
|
||||
broadcastBoard(server);
|
||||
broadcastButtons(server);
|
||||
broadcastTitle(server);
|
||||
}
|
||||
|
||||
function handleDeclineTakeback(server: GameServer): void {
|
||||
server.takebackRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
||||
|
||||
function handleCancelTakebackRequest(server: GameServer): void {
|
||||
server.takebackRequesterId = null;
|
||||
broadcastButtons(server);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { broadcastTitle } from '../view/title-renderer';
|
||||
import { GameServer } from '../game-server';
|
||||
import { Message } from './messages';
|
||||
|
||||
export interface UpdateDisplayNameMessage extends Message {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function handleUpdateDisplayName(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
message: UpdateDisplayNameMessage,
|
||||
): void {
|
||||
const newDisplayName = message.displayName.trim();
|
||||
|
||||
if (!newDisplayName) {
|
||||
conn.sendMessage('error', 'Display name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDisplayName.length > 20) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'Display name cannot be longer than 20 characters.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDisplayName === conn.name) {
|
||||
return; // No change, do nothing
|
||||
}
|
||||
|
||||
conn.name = newDisplayName;
|
||||
server.webSocketHandler.setPlayerName(conn.id, newDisplayName);
|
||||
broadcastTitle(server);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { WS } from '.';
|
||||
|
||||
export type PlayerId = string;
|
||||
|
||||
export class PlayerConnection {
|
||||
id: PlayerId;
|
||||
name: string;
|
||||
ws: WS;
|
||||
|
||||
constructor(id: string, name: string, ws: WS) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
public sendMessage(severity: 'info' | 'error', message: string) {
|
||||
console.log(`Sending message ${message} to player ${this.id}`);
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'client-info',
|
||||
message: message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { GameServer } from '../game-server';
|
||||
|
||||
export function broadcastAnimation(server: GameServer, animation: string) {
|
||||
server.connections.forEach((conn: PlayerConnection) =>
|
||||
broadcastAnimationToPlayer(conn, animation),
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastAnimationToPlayer(
|
||||
conn: PlayerConnection,
|
||||
animation: string,
|
||||
) {
|
||||
conn.ws.send({
|
||||
type: 'animation',
|
||||
animation: animation,
|
||||
});
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { GomokuGame } from '../game/game-instance';
|
||||
|
||||
export function renderGameBoardHtml(
|
||||
game: GomokuGame,
|
||||
isPlayerToPlay: boolean,
|
||||
): string {
|
||||
// Check the last move, so that the stone can be highlighted
|
||||
const lastMove = game.history[game.history.length - 1];
|
||||
|
||||
let boardHtml = '<div id="game-board" class="game-board-grid">';
|
||||
for (let row = 0; row < game.board.length; row++) {
|
||||
for (let col = 0; col < game.board[row].length; col++) {
|
||||
const stone = game.board[row][col];
|
||||
const intersectionId = `intersection-${row}-${col}`;
|
||||
let stoneHtml = '';
|
||||
if (stone) {
|
||||
const colorClass =
|
||||
stone === 'black' ? 'stone-black-heart' : 'stone-white-heart';
|
||||
const lastMoveClass =
|
||||
col == lastMove.col && row == lastMove.row ? 'last-move' : '';
|
||||
stoneHtml = `<div class="${colorClass} ${lastMoveClass}"></div>`;
|
||||
}
|
||||
|
||||
// Calculate top and left for absolute positioning, offset by half the intersection div size
|
||||
const top = row * 7.142857142857142;
|
||||
const left = col * 7.142857142857142;
|
||||
|
||||
// HTMX attributes for making a move
|
||||
const wsAttrs = isPlayerToPlay && !stone ? `ws-send="click"` : '';
|
||||
|
||||
boardHtml += `
|
||||
<div
|
||||
id="${intersectionId}"
|
||||
class="intersection"
|
||||
data-row="${row}"
|
||||
data-col="${col}"
|
||||
style="top: ${top}%; left: ${left}%;"
|
||||
${wsAttrs}
|
||||
>
|
||||
${stoneHtml}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
boardHtml += `</div>`;
|
||||
return boardHtml;
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
import { Html } from '@elysiajs/html';
|
||||
import { GomokuGame } from '../game/game-instance';
|
||||
import { PlayerConnection } from '../player-connection';
|
||||
import { GameServer } from '../game-server';
|
||||
|
||||
export function createStoneSvg(
|
||||
color: 'black' | 'white',
|
||||
isHighlighted: boolean = false,
|
||||
): JSX.Element {
|
||||
const colorClass =
|
||||
color === 'black' ? 'stone-black-heart' : 'stone-white-heart';
|
||||
const lastMoveClass = isHighlighted ? 'last-move' : '';
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={`${colorClass} ${lastMoveClass}`}
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
|
||||
fill="none"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastBoard(server: GameServer) {
|
||||
server.connections.forEach((conn: PlayerConnection) =>
|
||||
broadcastBoardToPlayer(server, conn),
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastBoardToPlayer(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
) {
|
||||
const isToPlay =
|
||||
server.gomoku.currentPlayerColor == server.getPlayerColor(conn);
|
||||
const updatedBoardHtml = renderGameBoardHtml(server.gomoku, isToPlay);
|
||||
conn.ws.send(updatedBoardHtml);
|
||||
console.log(`Sent board for game ${server.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
function renderGameBoardHtml(
|
||||
game: GomokuGame,
|
||||
isPlayerToPlay: boolean,
|
||||
): string {
|
||||
// Check the last move, so that the stone can be highlighted
|
||||
const lastMove = game.history[game.history.length - 1];
|
||||
|
||||
let boardHtml = '<div id="game-board" class="game-board-grid">';
|
||||
for (let row = 0; row < game.board.length; row++) {
|
||||
for (let col = 0; col < game.board[row].length; col++) {
|
||||
const stone = game.board[row][col];
|
||||
const intersectionId = `intersection-${row}-${col}`;
|
||||
let stoneHtml: JSX.Element | null;
|
||||
if (stone) {
|
||||
const isLastMove = col == lastMove.col && row == lastMove.row;
|
||||
stoneHtml = createStoneSvg(stone, isLastMove);
|
||||
} else {
|
||||
stoneHtml = null;
|
||||
}
|
||||
|
||||
// Calculate top and left for absolute positioning, offset by half the intersection div size
|
||||
const top = row * 7.142857142857142;
|
||||
const left = col * 7.142857142857142;
|
||||
|
||||
// HTMX attributes for making a move
|
||||
const wsAttrs = isPlayerToPlay && !stone ? `ws-send="click"` : '';
|
||||
|
||||
boardHtml += `
|
||||
<div
|
||||
id="${intersectionId}"
|
||||
class="intersection"
|
||||
data-row="${row}"
|
||||
data-col="${col}"
|
||||
style="top: ${top}%; left: ${left}%;"
|
||||
${wsAttrs}
|
||||
>
|
||||
${stoneHtml ? stoneHtml : ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
boardHtml += `</div>`;
|
||||
return boardHtml;
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
import { Html } from '@elysiajs/html';
|
||||
import { PlayerConnection } from '../player-connection';
|
||||
import { GameServer } from '../game-server';
|
||||
|
||||
export function broadcastButtons(server: GameServer) {
|
||||
server.connections.forEach((conn: PlayerConnection) =>
|
||||
broadcastButtonsToPlayer(server, conn),
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastButtonsToPlayer(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
) {
|
||||
const buttons: JSX.Element[] = [];
|
||||
let title: string | undefined;
|
||||
|
||||
if (server.gomoku.status == 'playing' && server.getPlayerColor(conn)) {
|
||||
if (server.takebackRequesterId) {
|
||||
if (server.takebackRequesterId === conn.id) {
|
||||
title = 'You requested a takeback';
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-takeback-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
title = 'Your opponent requests a takeback';
|
||||
buttons.push(
|
||||
<button
|
||||
id="accept-takeback-button"
|
||||
class="accept-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-takeback-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (server.drawRequesterId) {
|
||||
if (server.drawRequesterId === conn.id) {
|
||||
title = 'You requested a draw';
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-draw-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
title = 'Your opponent offers a draw';
|
||||
buttons.push(
|
||||
<button id="accept-draw-button" class="accept-button" ws-send="click">
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-draw-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buttons.push(
|
||||
<button id="resign-button" ws-send="click">
|
||||
<svg class="icon" alt="Resign">
|
||||
<use href="/icons/resign.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button id="takeback-button" ws-send="click">
|
||||
<svg class="icon" alt="Takeback">
|
||||
<use href="/icons/undo.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button id="draw-button" ws-send="click">
|
||||
<svg class="icon" alt="Draw">
|
||||
<use href="/icons/draw.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (server.gomoku.status === 'finished') {
|
||||
if (server.rematchRequesterId) {
|
||||
if (server.rematchRequesterId === conn.id) {
|
||||
title = 'You requested a rematch';
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-rematch-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
title = 'Your opponent requests a rematch';
|
||||
buttons.push(
|
||||
<button
|
||||
id="accept-rematch-button"
|
||||
class="accept-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-rematch-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buttons.push(
|
||||
<button id="rematch-button" ws-send="click">
|
||||
<svg class="icon" alt="Rematch">
|
||||
<use href="/icons/rotate-right.svg"></use>
|
||||
</svg>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (server.gomoku.status === 'waiting') {
|
||||
buttons.push(
|
||||
<button id="copy-link-button" onclick="copyGameLink()">
|
||||
<svg class="icon" alt="Copy">
|
||||
<use href="/icons/clipboard-copy.svg"></use>
|
||||
</svg>
|
||||
<span id="copy-link-text">Copy Link</span>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
conn.ws.send(
|
||||
<div id="button-box">
|
||||
<div id="button-box-title">{title}</div>
|
||||
<div id="button-box-buttons">{buttons}</div>
|
||||
</div>,
|
||||
);
|
||||
console.log(`Sent buttons for game ${server.id} to player ${conn.id}`);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { PlayerConnection } from '../player-connection';
|
||||
import { GameServer } from '../game-server';
|
||||
|
||||
export function broadcastSound(server: GameServer, sound: string) {
|
||||
server.connections.forEach((conn: PlayerConnection) =>
|
||||
broadcastSoundToPlayer(conn, sound),
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastSoundToPlayer(conn: PlayerConnection, sound: string) {
|
||||
conn.ws.send({
|
||||
type: 'sound',
|
||||
sound: sound,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { Html } from '@elysiajs/html';
|
||||
import { PlayerConnection } from '../player-connection';
|
||||
import { GameServer } from '../game-server';
|
||||
import { PlayerColor } from '../game/game-instance';
|
||||
|
||||
export function broadcastTitle(server: GameServer) {
|
||||
server.connections.forEach((conn: PlayerConnection) =>
|
||||
broadcastTitleToPlayer(server, conn),
|
||||
);
|
||||
}
|
||||
|
||||
export function broadcastTitleToPlayer(
|
||||
server: GameServer,
|
||||
conn: PlayerConnection,
|
||||
) {
|
||||
let message = '';
|
||||
switch (server.gomoku.status) {
|
||||
case 'waiting': {
|
||||
message = 'Waiting for players...';
|
||||
break;
|
||||
}
|
||||
case 'playing': {
|
||||
const blackTag = playerTag(server, server.blackPlayerId!, 'black');
|
||||
const whiteTag = playerTag(server, server.whitePlayerId!, 'white');
|
||||
message = `${blackTag} vs ${whiteTag}`;
|
||||
break;
|
||||
}
|
||||
case 'finished': {
|
||||
switch (server.gomoku.winnerColor) {
|
||||
case 'draw': {
|
||||
message = 'Game ended in draw.';
|
||||
break;
|
||||
}
|
||||
case 'black': {
|
||||
const name = server.connections.get(server.blackPlayerId!)?.name;
|
||||
message = `${name} wins!`;
|
||||
break;
|
||||
}
|
||||
case 'white': {
|
||||
const name = server.connections.get(server.whitePlayerId!)?.name;
|
||||
message = `${name} wins!`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
conn.ws.send(<div id="title-box">{message}</div>);
|
||||
console.log(`Sent title for game ${server.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
function playerTag(server: GameServer, playerId: string, color: PlayerColor) {
|
||||
const connectionIcon = server.connections.has(playerId)
|
||||
? ''
|
||||
: `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
|
||||
var turnClass =
|
||||
server.gomoku.currentPlayerColor === color ? 'player-to-play' : '';
|
||||
const classes = `player-name ${'player-' + color} ${turnClass}`.trim();
|
||||
return (
|
||||
<span class={classes}>
|
||||
{server.connections.get(playerId)?.name}
|
||||
{connectionIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,719 +1,11 @@
|
|||
import { ElysiaWS } from 'elysia/dist/ws';
|
||||
import { Html } from '@elysiajs/html';
|
||||
import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance';
|
||||
import { renderGameBoardHtml } from './view/board-renderer';
|
||||
import {
|
||||
Message,
|
||||
MakeMoveMessage,
|
||||
ResignationMessage,
|
||||
TakebackMessage,
|
||||
DrawMessage,
|
||||
RematchMessage,
|
||||
RedirectToGameMessage,
|
||||
UpdateDisplayNameMessage,
|
||||
} from './messages';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>;
|
||||
|
||||
class PlayerConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ws: WS;
|
||||
|
||||
constructor(id: string, name: string, ws: WS) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
public sendMessage(severity: 'info' | 'error', message: string) {
|
||||
console.log(`Sending message ${message} to player ${this.id}`);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
class GameServer {
|
||||
id: string;
|
||||
gomoku: GomokuGame;
|
||||
connections: Map<string, PlayerConnection>;
|
||||
blackPlayerId?: string;
|
||||
whitePlayerId?: string;
|
||||
takebackRequesterId: string | null = null;
|
||||
drawRequesterId: string | null = null;
|
||||
rematchRequesterId: string | null = null;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
private webSocketHandler: WebSocketHandler,
|
||||
) {
|
||||
this.id = id;
|
||||
this.gomoku = new GomokuGame();
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
public handleConnection(ws: WS) {
|
||||
const { playerId } = ws.data.query;
|
||||
if (this.connections.has(playerId)) {
|
||||
const existingConn = this.connections.get(playerId)!;
|
||||
existingConn.ws = ws; // Update with new WebSocket
|
||||
console.log(
|
||||
`Updated connection for player ${playerId} in game ${this.id}, replacing old WS.`,
|
||||
);
|
||||
} else {
|
||||
const playerName = this.webSocketHandler.getPlayerName(playerId); // Retrieve name or use ID
|
||||
const conn = new PlayerConnection(playerId, playerName, ws);
|
||||
this.connections.set(playerId, conn);
|
||||
console.log(
|
||||
`Created new connection with player ${conn.id} in game ${this.id}`,
|
||||
);
|
||||
|
||||
if (!this.blackPlayerId) {
|
||||
this.blackPlayerId = conn.id;
|
||||
} else if (!this.whitePlayerId) {
|
||||
this.whitePlayerId = conn.id;
|
||||
}
|
||||
}
|
||||
if (this.whitePlayerId && this.blackPlayerId) {
|
||||
this.gomoku.status = 'playing';
|
||||
}
|
||||
|
||||
this.broadcastBoard();
|
||||
this.broadcastButtons();
|
||||
this.broadcastTitle();
|
||||
}
|
||||
|
||||
public handleDisconnect(ws: WS) {
|
||||
const { playerId } = ws.data.query;
|
||||
this.connections.delete(playerId);
|
||||
this.broadcastTitle();
|
||||
}
|
||||
|
||||
public broadcastBoard() {
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastBoardToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastTitle() {
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastTitleToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastButtons() {
|
||||
this.connections.forEach((conn: PlayerConnection) =>
|
||||
this.broadcastButtonsToPlayer(conn),
|
||||
);
|
||||
}
|
||||
|
||||
public broadcastBoardToPlayer(conn: PlayerConnection) {
|
||||
const isToPlay =
|
||||
this.gomoku.currentPlayerColor == this.getPlayerColor(conn);
|
||||
const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay);
|
||||
conn.ws.send(updatedBoardHtml);
|
||||
console.log(`Sent board for game ${this.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
public broadcastTitleToPlayer(conn: PlayerConnection) {
|
||||
let message = '';
|
||||
switch (this.gomoku.status) {
|
||||
case 'waiting': {
|
||||
message = 'Waiting for players...';
|
||||
break;
|
||||
}
|
||||
case 'playing': {
|
||||
const blackTag = this.playerTag(this.blackPlayerId!, 'black');
|
||||
const whiteTag = this.playerTag(this.whitePlayerId!, 'white');
|
||||
message = `${blackTag} vs ${whiteTag}`;
|
||||
break;
|
||||
}
|
||||
case 'finished': {
|
||||
switch (this.gomoku.winnerColor) {
|
||||
case 'draw': {
|
||||
message = 'Game ended in draw.';
|
||||
break;
|
||||
}
|
||||
case 'black': {
|
||||
const name = this.connections.get(this.blackPlayerId!)?.name;
|
||||
message = `${name} wins!`;
|
||||
break;
|
||||
}
|
||||
case 'white': {
|
||||
const name = this.connections.get(this.whitePlayerId!)?.name;
|
||||
message = `${name} wins!`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
conn.ws.send(<div id="title-box">{message}</div>);
|
||||
console.log(`Sent title for game ${this.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
public broadcastButtonsToPlayer(conn: PlayerConnection) {
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) {
|
||||
if (this.takebackRequesterId) {
|
||||
if (this.takebackRequesterId === conn.id) {
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-takeback-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Cancel Takeback Request
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<button
|
||||
id="accept-takeback-button"
|
||||
class="accept-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
Accept Takeback
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-takeback-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Decline Takeback
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (this.drawRequesterId) {
|
||||
if (this.drawRequesterId === conn.id) {
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-draw-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Cancel Draw Request
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<button
|
||||
id="accept-draw-button"
|
||||
class="accept-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
Accept Draw
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-draw-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Decline Draw
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buttons.push(
|
||||
<button id="resign-button" ws-send="click">
|
||||
<svg class="icon" alt="Resign">
|
||||
<use href="/icons/resign.svg"></use>
|
||||
</svg>
|
||||
Resign
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button id="takeback-button" ws-send="click">
|
||||
<svg class="icon" alt="Takeback">
|
||||
<use href="/icons/undo.svg"></use>
|
||||
</svg>
|
||||
Takeback
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button id="draw-button" ws-send="click">
|
||||
<svg class="icon" alt="Draw">
|
||||
<use href="/icons/draw.svg"></use>
|
||||
</svg>
|
||||
Draw
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (this.gomoku.status === 'finished') {
|
||||
if (this.rematchRequesterId) {
|
||||
if (this.rematchRequesterId === conn.id) {
|
||||
buttons.push(
|
||||
<button
|
||||
id="cancel-rematch-request-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Cancel">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Cancel Rematch Request
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<button
|
||||
id="accept-rematch-button"
|
||||
class="accept-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Accept">
|
||||
<use href="/icons/accept.svg"></use>
|
||||
</svg>
|
||||
Accept Rematch
|
||||
</button>,
|
||||
);
|
||||
buttons.push(
|
||||
<button
|
||||
id="decline-rematch-button"
|
||||
class="decline-button"
|
||||
ws-send="click"
|
||||
>
|
||||
<svg class="icon" alt="Decline">
|
||||
<use href="/icons/decline.svg"></use>
|
||||
</svg>
|
||||
Decline Rematch
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
buttons.push(
|
||||
<button id="rematch-button" ws-send="click">
|
||||
<svg class="icon" alt="Rematch">
|
||||
<use href="/icons/rotate-right.svg"></use>
|
||||
</svg>
|
||||
Rematch
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
} else if (this.gomoku.status === 'waiting') {
|
||||
buttons.push(
|
||||
<button id="copy-link-button" onclick="copyGameLink()">
|
||||
<svg class="icon" alt="Copy">
|
||||
<use href="/icons/clipboard-copy.svg"></use>
|
||||
</svg>
|
||||
<span id="copy-link-text">Click to copy game link!</span>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
conn.ws.send(<div id="button-box">{buttons}</div>);
|
||||
console.log(`Sent buttons for game ${this.id} to player ${conn.id}`);
|
||||
}
|
||||
|
||||
private playerTag(playerId: string, color: PlayerColor) {
|
||||
const connectionIcon = this.connections.has(playerId)
|
||||
? ''
|
||||
: `<img src="/icons/disconnected.svg" alt="Disconnected" class="icon" />`;
|
||||
var turnClass =
|
||||
this.gomoku.currentPlayerColor === color ? 'player-to-play' : '';
|
||||
const classes = `player-name ${'player-' + color} ${turnClass}`.trim();
|
||||
return (
|
||||
<span class={classes}>
|
||||
{this.connections.get(playerId)?.name}
|
||||
{connectionIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
public handleMessage(ws: WS, message: Message): void {
|
||||
const conn = this.connections.get(ws.data.query.playerId);
|
||||
if (!conn) {
|
||||
console.error(
|
||||
`Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Handling ${message.type} message in game ${this.id} from player ${conn.id}: ${JSON.stringify(message)}`,
|
||||
);
|
||||
switch (message.type) {
|
||||
case 'make_move': {
|
||||
this.handleMakeMove(conn, message as MakeMoveMessage);
|
||||
break;
|
||||
}
|
||||
case 'resign': {
|
||||
this.handleResignation(conn, message as ResignationMessage);
|
||||
break;
|
||||
}
|
||||
case 'takeback': {
|
||||
this.handleTakebackMessage(conn, message as TakebackMessage);
|
||||
break;
|
||||
}
|
||||
case 'draw': {
|
||||
this.handleDrawMessage(conn, message as DrawMessage);
|
||||
break;
|
||||
}
|
||||
case 'rematch': {
|
||||
this.handleRematchMessage(conn, message as RematchMessage);
|
||||
break;
|
||||
}
|
||||
case 'update_display_name': {
|
||||
this.handleUpdateDisplayName(conn, message as UpdateDisplayNameMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getPlayerColor(conn: PlayerConnection): PlayerColor | undefined {
|
||||
if (this.blackPlayerId === conn.id) {
|
||||
return 'black';
|
||||
} else if (this.whitePlayerId === conn.id) {
|
||||
return 'white';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMakeMove(
|
||||
conn: PlayerConnection,
|
||||
message: MakeMoveMessage,
|
||||
): void {
|
||||
const { row, col } = message;
|
||||
|
||||
var playerColor;
|
||||
if (this.blackPlayerId === conn.id) {
|
||||
playerColor = 'black';
|
||||
} else if (this.whitePlayerId == conn.id) {
|
||||
playerColor = 'white';
|
||||
} else {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not a player in this game, you cannot make a move!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.gomoku.currentPlayerColor !== playerColor) {
|
||||
conn.sendMessage('error', "It's not your turn");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.takebackRequesterId || this.drawRequesterId) {
|
||||
this.takebackRequesterId = null;
|
||||
this.drawRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
const stateBeforeMove = this.gomoku.status;
|
||||
const result = this.gomoku.makeMove(playerColor, row, col);
|
||||
if (result.success) {
|
||||
this.broadcastBoard();
|
||||
this.broadcastTitle();
|
||||
// We only need to re-send buttons when the game state changes
|
||||
if (stateBeforeMove != this.gomoku.status) {
|
||||
this.broadcastButtons();
|
||||
}
|
||||
console.log(
|
||||
`Move made in game ${this.id} by ${conn.id}: (${row}, ${col})`,
|
||||
);
|
||||
} else {
|
||||
conn.sendMessage('error', result.error!);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResignation(
|
||||
conn: PlayerConnection,
|
||||
message: ResignationMessage,
|
||||
): void {
|
||||
console.log(
|
||||
`Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`,
|
||||
);
|
||||
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage('error', 'You can only resign from an active game.');
|
||||
return;
|
||||
}
|
||||
|
||||
const resigningPlayerColor = this.getPlayerColor(conn);
|
||||
if (!resigningPlayerColor) {
|
||||
conn.sendMessage('error', 'You are not a player in this game.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.gomoku.resign(resigningPlayerColor);
|
||||
this.broadcastBoard();
|
||||
this.broadcastTitle();
|
||||
this.broadcastButtons();
|
||||
|
||||
console.log(`Player ${conn.id} resigned from game ${this.id}`);
|
||||
}
|
||||
|
||||
private handleTakebackMessage(
|
||||
conn: PlayerConnection,
|
||||
message: TakebackMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
this.handleRequestTakeback(conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!this.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleAcceptTakeback();
|
||||
break;
|
||||
case 'decline':
|
||||
if (!this.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'No takeback has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleDeclineTakeback();
|
||||
break;
|
||||
case 'cancel':
|
||||
if (this.takebackRequesterId !== conn.id) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not the one who requested a takeback.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.handleCancelTakebackRequest();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDrawMessage(
|
||||
conn: PlayerConnection,
|
||||
message: DrawMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'playing') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in an active game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
this.handleRequestDraw(conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!this.drawRequesterId) {
|
||||
conn.sendMessage('error', 'No draw has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleAcceptDraw();
|
||||
break;
|
||||
case 'decline':
|
||||
if (!this.drawRequesterId) {
|
||||
conn.sendMessage('error', 'No draw has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleDeclineDraw();
|
||||
break;
|
||||
case 'cancel':
|
||||
if (this.drawRequesterId !== conn.id) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not the one who requested a draw.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.handleCancelDrawRequest();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleRematchMessage(
|
||||
conn: PlayerConnection,
|
||||
message: RematchMessage,
|
||||
): void {
|
||||
if (this.gomoku.status !== 'finished') {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You can only perform this action in a finished game.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (message.action) {
|
||||
case 'request':
|
||||
this.handleRequestRematch(conn);
|
||||
break;
|
||||
case 'accept':
|
||||
if (!this.rematchRequesterId) {
|
||||
conn.sendMessage('error', 'No rematch has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleAcceptRematch();
|
||||
break;
|
||||
case 'decline':
|
||||
if (!this.rematchRequesterId) {
|
||||
conn.sendMessage('error', 'No rematch has been requested.');
|
||||
return;
|
||||
}
|
||||
this.handleDeclineRematch();
|
||||
break;
|
||||
case 'cancel':
|
||||
if (this.rematchRequesterId !== conn.id) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'You are not the one who requested a rematch.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.handleCancelRematchRequest();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleRequestTakeback(conn: PlayerConnection): void {
|
||||
if (this.gomoku.history.length === 0) {
|
||||
conn.sendMessage('error', 'There are no moves to take back.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.drawRequesterId) {
|
||||
conn.sendMessage('error', 'A draw has already been requested.');
|
||||
return;
|
||||
}
|
||||
this.takebackRequesterId = conn.id;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleAcceptTakeback(): void {
|
||||
this.gomoku.undoMove();
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastBoard();
|
||||
this.broadcastButtons();
|
||||
this.broadcastTitle();
|
||||
}
|
||||
|
||||
private handleDeclineTakeback(): void {
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleRequestDraw(conn: PlayerConnection): void {
|
||||
if (this.takebackRequesterId) {
|
||||
conn.sendMessage('error', 'A takeback has already been requested.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawRequesterId = conn.id;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleAcceptDraw(): void {
|
||||
this.gomoku.declareDraw();
|
||||
this.drawRequesterId = null;
|
||||
this.broadcastBoard();
|
||||
this.broadcastButtons();
|
||||
this.broadcastTitle();
|
||||
}
|
||||
|
||||
private handleDeclineDraw(): void {
|
||||
this.drawRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleRequestRematch(conn: PlayerConnection): void {
|
||||
this.rematchRequesterId = conn.id;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleAcceptRematch(): void {
|
||||
const newGameId = this.webSocketHandler.createGame(
|
||||
undefined,
|
||||
this.whitePlayerId,
|
||||
this.blackPlayerId,
|
||||
);
|
||||
const redirectMessage: RedirectToGameMessage = {
|
||||
type: 'redirect_to_game',
|
||||
gameId: newGameId,
|
||||
};
|
||||
this.connections.forEach((c) => {
|
||||
c.ws.send(redirectMessage);
|
||||
});
|
||||
}
|
||||
|
||||
private handleDeclineRematch(): void {
|
||||
this.rematchRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleCancelTakebackRequest(): void {
|
||||
this.takebackRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleCancelDrawRequest(): void {
|
||||
this.drawRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleCancelRematchRequest(): void {
|
||||
this.rematchRequesterId = null;
|
||||
this.broadcastButtons();
|
||||
}
|
||||
|
||||
private handleUpdateDisplayName(
|
||||
conn: PlayerConnection,
|
||||
message: UpdateDisplayNameMessage,
|
||||
): void {
|
||||
const newDisplayName = message.displayName.trim();
|
||||
|
||||
if (!newDisplayName) {
|
||||
conn.sendMessage('error', 'Display name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDisplayName.length > 20) {
|
||||
conn.sendMessage(
|
||||
'error',
|
||||
'Display name cannot be longer than 20 characters.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDisplayName === conn.name) {
|
||||
return; // No change, do nothing
|
||||
}
|
||||
|
||||
conn.name = newDisplayName;
|
||||
this.webSocketHandler.setPlayerName(conn.id, newDisplayName);
|
||||
this.broadcastTitle();
|
||||
}
|
||||
}
|
||||
import { WS } from '.';
|
||||
import { GameServer } from './game-server';
|
||||
import { Message } from './messages/messages';
|
||||
|
||||
export class WebSocketHandler {
|
||||
private games: Map<String, GameServer>;
|
||||
private playerNames: Map<string, string>;
|
||||
public games: Map<String, GameServer>;
|
||||
public playerNames: Map<string, string>;
|
||||
|
||||
constructor() {
|
||||
this.games = new Map();
|
||||
|
@ -722,12 +14,13 @@ export class WebSocketHandler {
|
|||
|
||||
public handleConnection(ws: WS): void {
|
||||
const { gameId, playerId } = ws.data.query;
|
||||
console.log(`Player ${playerId} is opening a connection to game ${gameId}`);
|
||||
if (this.games.has(gameId)) {
|
||||
const game = this.games.get(gameId)!;
|
||||
game.handleConnection(ws);
|
||||
} else {
|
||||
ws.send('Error: game not found');
|
||||
ws.close();
|
||||
this.createGame(gameId);
|
||||
this.games.get(gameId)!.handleConnection(ws);
|
||||
}
|
||||
}
|
||||
|
||||
|
|