Merge pull request #1 from evanpelle/vue

Vue
This commit is contained in:
evanpelle
2024-10-13 11:10:14 -07:00
committed by GitHub
33 changed files with 1079 additions and 602 deletions
+8 -5
View File
@@ -162,21 +162,24 @@
* disable double tap on mobile DONE 10/6/2024
* donate troops button DONE 10/7/2024
* Make fake humans spawn by their country DONE 10/9/2024
* UI: leader board DONE 10/12/2024
* single player mode DONE 10/12/2024
* single player select map DONE 10/12/2024
* implement private game
* private game can select map
* optimize sendBoat function
* Test on android
* Increase disk size
* NPC more likely to accept alliance fewer alliance player has
* fake humans target enemies
* create private lobby menu
* better NPC relation logic
* surface NPC relations
* block user inputs if too far behind server
* BUG: FakeHuman don't be enemy if don't share border (or send boat)
* store cookies
* UI: leader board
* Load terrain dataImage in background
* BUG: half encircle Antartica causes capture
* improve front page (make map larger?)
* Add additional maps
* add offline mode
* REFACTOR: give terranullius an ID, game.player() returns terranullius
* REFACTOR: ocean is considered TerraNullius ?
* Make icons svgs
+72 -19
View File
@@ -22,6 +22,7 @@
"googleapis": "^143.0.0",
"hammerjs": "^2.0.8",
"jimp": "^0.22.12",
"lit": "^3.2.1",
"msgpack5": "^6.0.2",
"node-addon-api": "^8.1.0",
"node-gyp": "^10.2.0",
@@ -43,7 +44,7 @@
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.12",
"@types/mocha": "^10.0.7",
"@types/node": "^22.5.2",
"@types/node": "^22.7.5",
"@types/sinon": "^17.0.3",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.11",
@@ -69,7 +70,7 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"typescript": "^5.5.4",
"typescript": "^5.6.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
@@ -3655,6 +3656,21 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz",
"integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz",
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
"node_modules/@npmcli/agent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz",
@@ -4428,9 +4444,9 @@
}
},
"node_modules/@types/node": {
"version": "22.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.2.tgz",
"integrity": "sha512-acJsPTEqYqulZS/Yp/S3GgeE6GZ0qYODUR8aVr/DkhHQ8l9nd4j5x1/ZJy9/gHrRlFMqkO6i0I3E27Alu4jjPg==",
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
@@ -4542,6 +4558,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -9990,6 +10012,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/lit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz",
"integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.1.0",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-element": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz",
"integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-html": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz",
"integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/load-bmfont": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz",
@@ -11355,9 +11408,9 @@
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true,
"license": "ISC"
},
@@ -11423,9 +11476,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.41",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"dev": true,
"funding": [
{
@@ -11444,8 +11497,8 @@
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.0",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -12573,9 +12626,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -13470,9 +13523,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+5 -3
View File
@@ -9,7 +9,8 @@
"start:server-dev": "GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
"dev": "GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
"tunnel": "npm run build-prod && npm run start:server",
"test": "jest"
"test": "jest",
"tailwind": "tailwindcss build -i ./src/client/tailwind.css -o public/tailwind.css"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -19,7 +20,7 @@
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.12",
"@types/mocha": "^10.0.7",
"@types/node": "^22.5.2",
"@types/node": "^22.7.5",
"@types/sinon": "^17.0.3",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.11",
@@ -45,7 +46,7 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"typescript": "^5.5.4",
"typescript": "^5.6.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
@@ -67,6 +68,7 @@
"googleapis": "^143.0.0",
"hammerjs": "^2.0.8",
"jimp": "^0.22.12",
"lit": "^3.2.1",
"msgpack5": "^6.0.2",
"node-addon-api": "^8.1.0",
"node-gyp": "^10.2.0",
-242
View File
@@ -1,242 +0,0 @@
import {Config, getConfig} from "../core/configuration/Config";
import {GameID, Lobby, ServerMessage, ServerMessageSchema} from "../core/Schemas";
import {loadTerrainMap, TerrainMap} from "../core/game/TerrainMapLoader";
import {ClientGame, createClientGame} from "./ClientGame";
import backgroundImage from '../../resources/images/TerrainMapFrontPage.png';
import favicon from '../../resources/images/Favicon.png';
import {v4 as uuidv4} from 'uuid';
import './styles.css';
import {simpleHash} from "../core/Util";
import {PseudoRandom} from "../core/PseudoRandom";
const usernameKey: string = 'username';
class Client {
private terrainMap: Promise<TerrainMap>
private game: ClientGame
private lobbiesInterval: NodeJS.Timeout | null = null;
private isLobbyHighlighted: boolean = false;
private ip: Promise<string | null> = null
private config: Config
constructor() {
}
initialize(): void {
const storedUsername = localStorage.getItem(usernameKey)
if (storedUsername) {
const usernameInput = document.getElementById('username') as HTMLInputElement | null;
if (usernameInput) {
usernameInput.value = storedUsername
}
}
this.config = getConfig()
setFavicon()
this.terrainMap = loadTerrainMap()
this.startLobbyPolling()
this.ip = getClientIP()
}
private startLobbyPolling(): void {
this.fetchAndUpdateLobbies(); // Fetch immediately on start
this.lobbiesInterval = setInterval(() => this.fetchAndUpdateLobbies(), 1000);
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.updateLobbiesDisplay(lobbies);
} catch (error) {
console.error('Error fetching and updating lobbies:', error);
}
}
private updateLobbiesDisplay(lobbies: Lobby[]): void {
if (lobbies.length === 0) {
document.getElementById('lobby-button').style.display = 'none';
return;
}
const lobby = lobbies[0];
const lobbyButton = document.getElementById('lobby-button');
const nameElement = document.getElementById('lobby-name');
const timerElement = document.getElementById('lobby-timer');
const playerCountElement = document.getElementById('player-count');
if (lobbyButton) {
lobbyButton.style.display = 'flex';
lobbyButton.onclick = () => this.joinLobby(lobby);
// Preserve the highlighted state
lobbyButton.classList.toggle('highlighted', this.isLobbyHighlighted);
}
if (nameElement) nameElement.textContent = `Game ${lobby.id.substring(0, 3)}`;
if (timerElement) {
const timeRemaining = Math.max(0, Math.floor((lobby.msUntilStart) / 1000));
timerElement.textContent = `Starts in: ${timeRemaining}s`;
}
if (playerCountElement) playerCountElement.textContent = `Players: ${lobby.numClients}`;
if (lobbies.length > 1) {
const nextLobby = lobbies[1]
const nextGame = document.getElementById('next-game');
nextGame.textContent = `Next Game: ${nextLobby.id.substring(0, 3)}`
}
}
async fetchLobbies(): Promise<Lobby[]> {
const url = '/lobbies';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
}
const data = await response.json();
return data.lobbies;
} catch (error) {
console.error('Error fetching lobbies:', error);
throw error;
}
}
private async joinLobby(lobby: Lobby) {
console.log(`joining lobby ${lobby.id}`)
const lobbyButton = document.getElementById('lobby-button');
if (lobbyButton) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
lobbyButton.classList.toggle('highlighted', this.isLobbyHighlighted);
}
if (!this.isLobbyHighlighted) {
this.game.stop()
this.game = null
return
}
if (this.game != null) {
return;
}
const [terrainMap, clientIP] = await Promise.all([
this.terrainMap,
this.ip
]);
console.log(`got ip ${clientIP}`)
this.game = createClientGame(
refreshUsername,
uuidv4(),
uuidv4(),
clientIP,
lobby.id,
this.config,
terrainMap
);
this.game.join();
const g = this.game;
window.addEventListener('beforeunload', function (event) {
console.log('Browser is closing');
g.stop();
});
}
}
function refreshUsername(): string {
const usernameInput = document.getElementById('username') as HTMLInputElement | null;
if (usernameInput == null) {
console.warn('username element not found')
return "Anon"
}
if (usernameInput && usernameInput.value.trim()) {
const trimmedValue = usernameInput.value.trim();
localStorage.setItem(usernameKey, trimmedValue)
return trimmedValue
} else {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername
}
const newUsername = "Anon" + uuidToThreeDigits()
localStorage.setItem(usernameKey, newUsername)
return newUsername
}
}
function setupUsernameCallback(callback: (username: string) => void): void {
const usernameInput = document.getElementById('username') as HTMLInputElement | null;
if (usernameInput) {
usernameInput.addEventListener('input', () => {
const username = refreshUsername();
callback(username);
});
} else {
console.error('Username input element not found');
}
}
async function getClientIP(timeoutMs: number = 1000): Promise<string | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response: Response = await fetch('https://api.ipify.org?format=json', {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: {ip: string} = await response.json();
return data.ip;
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error('Request timed out');
} else {
console.error('Error fetching IP:', error.message);
}
} else {
console.error('An unknown error occurred');
}
return null;
} finally {
clearTimeout(timeoutId);
}
}
// Initialize the client when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Client().initialize();
});
document.body.style.backgroundImage = `url(${backgroundImage})`;
function setFavicon(): void {
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = favicon;
document.head.appendChild(link);
}
function uuidToThreeDigits(): string {
const uuid = uuidv4()
// Remove hyphens and convert to lowercase
const cleanUuid = uuid.replace(/-/g, '').toLowerCase();
// Convert hex string to decimal
const decimal = BigInt(`0x${cleanUuid}`);
// Get last 3 digits
const threeDigits = decimal % 1000n;
// Pad with leading zeros if necessary
return threeDigits.toString().padStart(3, '0');
}
+21 -13
View File
@@ -1,43 +1,52 @@
import {Executor} from "../core/execution/ExecutionManager";
import {Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, BoatEvent, Tile, PlayerType} from "../core/game/Game";
import {Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, BoatEvent, Tile, PlayerType, GameMap} from "../core/game/Game";
import {createGame} from "../core/game/GameImpl";
import {EventBus} from "../core/EventBus";
import {Config} from "../core/configuration/Config";
import {Config, getConfig} from "../core/configuration/Config";
import {createRenderer, GameRenderer} from "./graphics/GameRenderer";
import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler"
import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas";
import {TerrainMap} from "../core/game/TerrainMapLoader";
import {loadTerrainMap, TerrainMap} from "../core/game/TerrainMapLoader";
import {and, bfs, dist, manhattanDist} from "../core/Util";
import {TerrainLayer} from "./graphics/layers/TerrainLayer";
import {WinCheckExecution} from "../core/execution/WinCheckExecution";
import {SendAttackIntentEvent, SendSpawnIntentEvent, Transport} from "./Transport";
import {createCanvas} from "./graphics/Utils";
import {DisplayMessageEvent, MessageType} from "./graphics/layers/EventsDisplay";
import {placeName} from "./graphics/NameBoxCalculator";
import {v4 as uuidv4} from 'uuid';
export interface GameConfig {
isLocal: boolean
playerName: () => string
gameID: GameID
ip: string | null
map: GameMap
}
export function createClientGame(playerName: () => string, clientID: ClientID, playerID: PlayerID, ip: string | null, gameID: GameID, config: Config, terrainMap: TerrainMap): ClientGame {
export async function createClientGame(gameConfig: GameConfig): Promise<ClientGame> {
let eventBus = new EventBus()
const config = getConfig()
const clientID = uuidv4()
const playerID = uuidv4()
const terrainMap = await loadTerrainMap(gameConfig.map)
let game = createGame(terrainMap, eventBus, config)
const canvas = createCanvas()
let gameRenderer = createRenderer(canvas, game, eventBus, clientID)
const transport = new Transport(null, eventBus, gameID, clientID, playerID, playerName)
const transport = new Transport(gameConfig.isLocal, eventBus, gameConfig.gameID, clientID, playerID, config, gameConfig.playerName)
return new ClientGame(
clientID,
ip,
gameID,
gameConfig.ip,
eventBus,
game,
gameRenderer,
new InputHandler(canvas, eventBus),
new Executor(game, gameID),
new Executor(game, gameConfig.gameID),
transport,
)
}
@@ -56,7 +65,6 @@ export class ClientGame {
constructor(
private id: ClientID,
private clientIP: string | null,
private gameID: GameID,
private eventBus: EventBus,
private gs: Game,
private renderer: GameRenderer,
+46
View File
@@ -0,0 +1,46 @@
import {Config} from "../core/configuration/Config";
import {ClientMessage, ClientMessageSchema, Intent, ServerMessage, ServerTurnMessageSchema, Turn} from "../core/Schemas";
export class LocalServer {
private gameID = "LOCAL"
private turns: Turn[] = []
private intents: Intent[] = []
private endTurnIntervalID
constructor(private config: Config, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) {
}
start() {
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs());
this.clientConnect()
this.clientMessage({
type: "start",
turns: [],
})
}
onMessage(message: string) {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
this.intents.push(clientMsg.intent)
}
}
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.gameID,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
this.clientMessage({
type: "turn",
turn: pastTurn
})
}
}
+132
View File
@@ -0,0 +1,132 @@
import {ClientGame, createClientGame} from "./ClientGame";
import backgroundImage from '../../resources/images/TerrainMapFrontPage.png';
import favicon from '../../resources/images/Favicon.png';
import './PublicLobby';
import './UsernameInput';
import './styles.css';
import {UsernameInput} from "./UsernameInput";
import {SinglePlayerModal} from "./SinglePlayerModal";
import {GameMap} from "../core/game/Game";
const usernameKey: string = 'username';
class Client {
private game: ClientGame
private ip: Promise<string | null> = null
private usernameInput: UsernameInput | null = null;
constructor() {
}
initialize(): void {
this.usernameInput = document.querySelector('username-input') as UsernameInput;
if (!this.usernameInput) {
console.warn('Username input element not found');
}
setFavicon()
this.ip = getClientIP()
document.addEventListener('join-lobby', this.handleJoinLobby.bind(this));
document.addEventListener('leave-lobby', this.handleLeaveLobby.bind(this));
document.addEventListener('single-player', this.handleSinglePlayer.bind(this));
const singlePlayerButton = document.getElementById('single-player');
const modal = document.querySelector('single-player-modal') as SinglePlayerModal;
if (singlePlayerButton && modal instanceof SinglePlayerModal) {
singlePlayerButton.addEventListener('click', () => {
modal.open();
});
}
}
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail.lobby
console.log(`joining lobby ${lobby.id}`)
const clientIP = await this.ip
console.log(`got ip ${clientIP}`)
if (this.game != null) {
this.game.stop()
}
this.game = await createClientGame(
{
isLocal: event.detail.singlePlayer,
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
ip: clientIP,
map: event.detail.map,
}
);
this.game.join();
const g = this.game;
window.addEventListener('beforeunload', function (event) {
console.log('Browser is closing');
g.stop();
});
}
private async handleLeaveLobby(event: CustomEvent) {
this.game.stop()
this.game = null
}
private async handleSinglePlayer(event: CustomEvent) {
alert('coming soon')
}
}
async function getClientIP(timeoutMs: number = 1000): Promise<string | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response: Response = await fetch('https://api.ipify.org?format=json', {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: {ip: string} = await response.json();
return data.ip;
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error('Request timed out');
} else {
console.error('Error fetching IP:', error.message);
}
} else {
console.error('An unknown error occurred');
}
return null;
} finally {
clearTimeout(timeoutId);
}
}
// Initialize the client when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Client().initialize();
});
document.body.style.backgroundImage = `url(${backgroundImage})`;
function setFavicon(): void {
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = favicon;
document.head.appendChild(link);
}
+129
View File
@@ -0,0 +1,129 @@
import {LitElement, html, css} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {Lobby} from "../core/Schemas";
import {GameMap} from '../core/game/Game';
@customElement('public-lobby')
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null
static styles = css`
/* Add your styles here, based on your existing CSS */
.lobby-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 20rem;
margin: 0 auto;
padding: 1.5rem 2rem;
background-color: #2563eb;
color: white;
font-weight: bold;
border-radius: 0.5rem;
transition: background-color 0.3s ease-in-out;
}
.lobby-button:hover {
background-color: #1d4ed8;
}
.lobby-button.highlighted {
background-color: #16a34a;
}
.lobby-button.highlighted:hover {
background-color: #15803d;
}
.lobby-name { font-size: 1.5rem; }
.lobby-timer { font-size: 1.25rem; }
.player-count { font-size: 1rem; }
`;
connectedCallback() {
super.connectedCallback();
this.fetchAndUpdateLobbies(); // Fetch immediately on start
this.lobbiesInterval = window.setInterval(() => this.fetchAndUpdateLobbies(), 1000);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.lobbiesInterval !== null) {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.lobbies = lobbies;
} catch (error) {
console.error('Error fetching and updating lobbies:', error);
}
}
async fetchLobbies(): Promise<Lobby[]> {
const url = '/lobbies';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data.lobbies;
} catch (error) {
console.error('Error fetching lobbies:', error);
throw error;
}
}
render() {
if (this.lobbies.length === 0) {
return html``;
}
const lobby = this.lobbies[0];
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="lobby-button ${this.isLobbyHighlighted ? 'highlighted' : ''}"
>
<div class="lobby-name">Game ${lobby.id.substring(0, 3)}</div>
<div class="lobby-timer">Starts in: ${timeRemaining}s</div>
<div class="player-count">Players: ${lobby.numClients}</div>
</button>
`;
}
private lobbyClicked(lobby: Lobby) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
if (this.currLobby == null) {
this.currLobby = lobby
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby: lobby,
singlePlayer: false,
map: GameMap.World,
},
bubbles: true,
composed: true
}));
} else {
this.dispatchEvent(new CustomEvent('leave-lobby', {
detail: {lobby: this.currLobby},
bubbles: true,
composed: true
}));
this.currLobby = null
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {GameMap} from '../core/game/Game';
@customElement('single-player-modal')
export class SinglePlayerModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMap = GameMap.World;
static styles = css`
.modal-overlay {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
text-align: center;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background-color 0.3s;
display: inline-block;
margin-top: 20px;
}
button:hover {
background-color: #0056b3;
}
select {
padding: 8px;
font-size: 16px;
margin-top: 10px;
width: 200px;
}
`;
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Start Single Player Game</h2>
<div>
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMap)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${key}
</option>
`)}
</select>
</div>
<button @click=${this.startGame}>Start Game</button>
</div>
</div>
`;
}
public open() {
this.isModalOpen = true;
}
public close() {
this.isModalOpen = false;
}
private handleMapChange(e: Event) {
this.selectedMap = Number((e.target as HTMLSelectElement).value) as GameMap;
}
private startGame() {
console.log(`Starting single player game with map: ${GameMap[this.selectedMap]}`);
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
singlePlayer: true,
lobby: {
id: "LOCAL",
},
map: this.selectedMap,
},
bubbles: true,
composed: true
}));
this.close();
}
}
+33 -7
View File
@@ -1,6 +1,8 @@
import {Config} from "../core/configuration/Config"
import {EventBus, GameEvent} from "../core/EventBus"
import {AllianceRequest, AllPlayers, Cell, Player, PlayerID, PlayerType} from "../core/game/Game"
import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema} from "../core/Schemas"
import {LocalServer} from "./LocalServer"
export class SendAllianceRequestIntentEvent implements GameEvent {
@@ -67,14 +69,17 @@ export class SendDonateIntentEvent implements GameEvent {
export class Transport {
public onconnect: () => {}
private socket: WebSocket
private localServer: LocalServer
constructor(
public socket: WebSocket,
private isLocal: boolean,
private eventBus: EventBus,
private gameID: GameID,
private clientID: ClientID,
private playerID: PlayerID,
private config: Config,
private playerName: () => string,
) {
this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e))
@@ -89,6 +94,19 @@ export class Transport {
}
connect(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) {
if (this.isLocal) {
this.connectLocal(onconnect, onmessage, isActive)
} else {
this.connectRemote(onconnect, onmessage, isActive)
}
}
private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) {
this.localServer = new LocalServer(this.config, onconnect, onmessage)
this.localServer.start()
}
private connectRemote(onconnect: () => void, onmessage: (message: ServerMessage) => void, isActive: () => boolean) {
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`)
@@ -115,7 +133,7 @@ export class Transport {
}
joinGame(clientIP: string | null, numTurns: number) {
this.socket.send(
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
@@ -136,7 +154,7 @@ export class Transport {
clientID: this.clientID,
gameID: this.gameID,
})
this.socket.send(JSON.stringify(msg))
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
@@ -239,17 +257,25 @@ export class Transport {
}
private sendIntent(intent: Intent) {
if (this.socket.readyState === WebSocket.OPEN) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.clientID,
gameID: this.gameID,
intent: intent
})
this.socket.send(JSON.stringify(msg))
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
}
}
}
private sendMsg(msg: string) {
if (this.isLocal) {
this.localServer.onMessage(msg)
} else {
this.socket.send(msg)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {v4 as uuidv4} from 'uuid';
const usernameKey: string = 'username';
@customElement('username-input')
export class UsernameInput extends LitElement {
@state() private username: string = '';
static styles = css`
input {
width: 100%;
padding: 0.75rem;
background-color: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
font-size: 1rem;
line-height: 1.5;
color: #111827;
}
input:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
border-color: #3b82f6;
}
`;
public getCurrentUsername(): string {
return this.username;
}
connectedCallback() {
super.connectedCallback();
this.username = this.getStoredUsername();
this.dispatchUsernameEvent()
}
render() {
return html`
<input
type="text"
.value=${this.username}
@input=${this.handleInput}
placeholder="Enter your username"
maxlength="18"
>
`;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.username = input.value.trim();
this.storeUsername(this.username);
this.dispatchUsernameEvent()
}
private getStoredUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private dispatchUsernameEvent() {
this.dispatchEvent(new CustomEvent('username-change', {
detail: {username: this.username},
bubbles: true,
composed: true
}));
}
private generateNewUsername(): string {
const newUsername = "Anon" + this.uuidToThreeDigits();
this.storeUsername(newUsername);
return newUsername;
}
private uuidToThreeDigits(): string {
const uuid = uuidv4();
const cleanUuid = uuid.replace(/-/g, '').toLowerCase();
const decimal = BigInt(`0x${cleanUuid}`);
const threeDigits = decimal % 1000n;
return threeDigits.toString().padStart(3, '0');
}
}
+19 -5
View File
@@ -8,19 +8,33 @@ import {EventBus} from "../../core/EventBus";
import {TransformHandler} from "./TransformHandler";
import {Layer} from "./layers/Layer";
import {EventsDisplay} from "./layers/EventsDisplay";
import {RadialMenu} from "./layers/RadialMenu";
import {RadialMenu} from "./layers/radial/RadialMenu";
import {EmojiTable} from "./layers/radial/EmojiTable";
import {Leaderboard} from "./layers/Leaderboard";
export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: EventBus, clientID: ClientID): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas)
const emojiTable = document.querySelector('emoji-table') as EmojiTable;
if (!emojiTable || !(emojiTable instanceof EmojiTable)) {
console.error('EmojiTable element not found in the DOM');
}
const leaderboard = document.querySelector('leader-board') as Leaderboard;
if (!emojiTable || !(leaderboard instanceof Leaderboard)) {
console.error('EmojiTable element not found in the DOM');
}
leaderboard.clientID = clientID
const layers: Layer[] = [
new TerrainLayer(game),
new TerritoryLayer(game, eventBus),
new NameLayer(game, game.config().theme(), transformHandler, clientID),
new UILayer(eventBus, game, clientID, transformHandler),
new EventsDisplay(eventBus, game, clientID),
new RadialMenu(eventBus, game, transformHandler, clientID),
new RadialMenu(eventBus, game, transformHandler, clientID, emojiTable as EmojiTable),
leaderboard,
]
return new GameRenderer(game, eventBus, canvas, transformHandler, layers)
@@ -36,7 +50,7 @@ export class GameRenderer {
}
initialize() {
this.layers.forEach(l => l.init())
this.layers.forEach(l => l.init(this.game))
document.body.appendChild(this.canvas);
window.addEventListener('resize', () => this.resizeCanvas());
@@ -65,7 +79,7 @@ export class GameRenderer {
this.layers.forEach(l => {
if (l.shouldTransform()) {
l.render(this.context)
l.renderLayer(this.context)
}
})
@@ -73,7 +87,7 @@ export class GameRenderer {
this.layers.forEach(l => {
if (!l.shouldTransform()) {
l.render(this.context)
l.renderLayer(this.context)
}
})
+1 -1
View File
@@ -263,7 +263,7 @@ export class EventsDisplay implements Layer {
this.events[index] = event;
}
render(): void { }
renderLayer(): void { }
renderTable(): void {
if (this.events.length === 0) {
+3 -2
View File
@@ -1,8 +1,9 @@
import {Game} from "../../../core/game/Game"
import {TransformHandler} from "../TransformHandler"
export interface Layer {
init()
init(game: Game)
tick()
render(context: CanvasRenderingContext2D)
renderLayer(context: CanvasRenderingContext2D)
shouldTransform(): boolean
}
+179
View File
@@ -0,0 +1,179 @@
import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {Layer} from './Layer';
import {Game, Player} from '../../../core/game/Game';
import {ClientID} from '../../../core/Schemas';
interface Entry {
name: string
position: number
score: number
isMyPlayer: boolean
}
@customElement('leader-board')
export class Leaderboard extends LitElement implements Layer {
private game: Game
public clientID: ClientID
init(game: Game) {
this.game = game
}
tick() {
if (this._hidden && !this.game.inSpawnPhase()) {
this.showLeaderboard()
this.updateLeaderboard()
}
if (this._hidden) {
return
}
if (this.game.ticks() % 10 == 0) {
this.updateLeaderboard()
}
}
private updateLeaderboard() {
if (this.clientID == null) {
return
}
const myPlayer = this.game.players().find(p => p.clientID() == this.clientID)
if (myPlayer == null) {
return
}
const sorted = this.game.players()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())
this.players = sorted
.slice(0, 5)
.map((player, index) => ({
name: player.name(),
position: index + 1,
score: player.numTilesOwned(),
isMyPlayer: player == myPlayer
}));
if (this.players.find(p => p.isMyPlayer) == null) {
let place = 0
for (const p of sorted) {
place++
if (p == myPlayer) {
break
}
}
this.players.pop()
this.players.push({
name: myPlayer.name(),
position: place,
score: myPlayer.numTilesOwned(),
isMyPlayer: true,
})
}
this.requestUpdate()
}
renderLayer(context: CanvasRenderingContext2D) {
}
shouldTransform(): boolean {
return false
}
static styles = css`
:host {
display: block;
}
.leaderboard {
position: fixed;
top: 20px;
left: 20px;
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
width: 300px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #333;
color: white;
}
th {
background-color: #2C2C2C;
color: white;
}
.myPlayer {
font-weight: bold;
font-size: 1.2em;
}
tr:nth-child(even) {
background-color: #2C2C2C;
}
tr:hover {
background-color: #3A3A3A;
}
.hidden {
display: none !important;
}
`;
@property({type: Array})
players: Entry[] = [];
@state()
private _hidden = true;
render() {
return html`
<div class="leaderboard ${this._hidden ? 'hidden' : ''}">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Score</th>
</tr>
</thead>
<tbody>
${this.players
.map((player, index) => html`
<tr class="${player.isMyPlayer ? 'myPlayer' : 'none'}">
<td>${player.position}</td>
<td>${player.name.slice(0, 12)}</td>
<td>${player.score}</td>
</tr>
`)}
</tbody>
</table>
</div>
`;
}
hideLeaderboard() {
this._hidden = true;
this.requestUpdate();
}
showLeaderboard() {
this._hidden = false;
this.requestUpdate();
}
get isVisible() {
return !this._hidden;
}
}
+2 -2
View File
@@ -60,7 +60,7 @@ export class NameLayer implements Layer {
return true
}
public init() {
public init(game: Game) {
}
@@ -100,7 +100,7 @@ export class NameLayer implements Layer {
}
}
public render(mainContex: CanvasRenderingContext2D) {
public renderLayer(mainContex: CanvasRenderingContext2D) {
const [upperLeft, bottomRight] = this.transformHandler.screenBoundingRect()
for (const render of this.renders) {
render.isVisible = this.isVisible(render, upperLeft, bottomRight)
+2 -2
View File
@@ -17,7 +17,7 @@ export class TerrainLayer implements Layer {
tick() {
}
init() {
init(game: Game) {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d")
@@ -41,7 +41,7 @@ export class TerrainLayer implements Layer {
})
}
render(context: CanvasRenderingContext2D) {
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
+2 -2
View File
@@ -31,7 +31,7 @@ export class TerritoryLayer implements Layer {
tick() {
}
init() {
init(game: Game) {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d")
@@ -50,7 +50,7 @@ export class TerritoryLayer implements Layer {
})
}
render(context: CanvasRenderingContext2D) {
renderLayer(context: CanvasRenderingContext2D) {
this.renderTerritory()
this.context.putImageData(this.imageData, 0, 0);
context.drawImage(
+2 -2
View File
@@ -30,7 +30,7 @@ export class UILayer implements Layer {
}
render(context: CanvasRenderingContext2D) {
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
return
}
@@ -55,7 +55,7 @@ export class UILayer implements Layer {
tick() {
}
init() {
init(game: Game) {
this.createExitButton()
this.createWinModal()
this.initRightClickMenu()
@@ -0,0 +1,107 @@
import {LitElement, html, css} from 'lit';
import {customElement, state} from 'lit/decorators.js';
const emojiTable: string[][] = [
["😀", "😱", "🤩", "🎯", "🥺"],
["🪦", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🥳", "💀", "😭", "🤦‍♂️"],
["😎", "👎", "👍", "🥱", "💔"],
["❤️", "💰", "🤝", "🛡️", "💥"],
["🆘", "🕊️", "➡️", "⬅️", "↙️"],
["↖️", "↗️", "⬆️", "↘️", "⬇️"]
];
@customElement('emoji-table')
export class EmojiTable extends LitElement {
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3A3A3A;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
`;
@state()
private _hidden = true;
public onEmojiClicked: (emoji: string) => void = () => { }
render() {
return html`
<div class="emoji-table ${this._hidden ? 'hidden' : ''}">
${emojiTable.map(row => html`
<div class="emoji-row">
${row.map(emoji => html`
<button class="emoji-button" @click=${() => this.onEmojiClicked(emoji)}>
${emoji}
</button>
`)}
</div>
`)}
</div>
`;
}
hideTable() {
this._hidden = true;
this.requestUpdate();
}
showTable() {
this._hidden = false;
this.requestUpdate();
}
get isVisible() {
return !this._hidden;
}
}
@@ -1,21 +1,21 @@
import {EventBus} from "../../../core/EventBus";
import {AllPlayers, Cell, Game, Player} from "../../../core/game/Game";
import {ClientID} from "../../../core/Schemas";
import {and, bfs, dist, manhattanDist, manhattanDistWrapped, sourceDstOceanShore} from "../../../core/Util";
import {ContextMenuEvent, MouseUpEvent} from "../../InputHandler";
import {SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendBreakAllianceIntentEvent, SendDonateIntentEvent, SendEmojiIntentEvent, SendSpawnIntentEvent, SendTargetPlayerIntentEvent} from "../../Transport";
import {TransformHandler} from "../TransformHandler";
import {Layer} from "./Layer";
import {EventBus} from "../../../../core/EventBus";
import {AllPlayers, Cell, Game, Player} from "../../../../core/game/Game";
import {ClientID} from "../../../../core/Schemas";
import {and, bfs, dist, manhattanDist, manhattanDistWrapped, sourceDstOceanShore} from "../../../../core/Util";
import {ContextMenuEvent, MouseUpEvent} from "../../../InputHandler";
import {SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendBreakAllianceIntentEvent, SendDonateIntentEvent, SendEmojiIntentEvent, SendSpawnIntentEvent, SendTargetPlayerIntentEvent} from "../../../Transport";
import {TransformHandler} from "../../TransformHandler";
import {Layer} from "../Layer";
import * as d3 from 'd3';
import traitorIcon from '../../../../resources/images/TraitorIconWhite.png';
import allianceIcon from '../../../../resources/images/AllianceIconWhite.png';
import boatIcon from '../../../../resources/images/BoatIconWhite.png';
import swordIcon from '../../../../resources/images/SwordIconWhite.png';
import targetIcon from '../../../../resources/images/TargetIconWhite.png';
import emojiIcon from '../../../../resources/images/EmojiIconWhite.png';
import disabledIcon from '../../../../resources/images/DisabledIcon.png';
import donateIcon from '../../../../resources/images/DonateIconWhite.png';
import {MessageType} from "./EventsDisplay";
import traitorIcon from '../../../../../resources/images/TraitorIconWhite.png';
import allianceIcon from '../../../../../resources/images/AllianceIconWhite.png';
import boatIcon from '../../../../../resources/images/BoatIconWhite.png';
import swordIcon from '../../../../../resources/images/SwordIconWhite.png';
import targetIcon from '../../../../../resources/images/TargetIconWhite.png';
import emojiIcon from '../../../../../resources/images/EmojiIconWhite.png';
import disabledIcon from '../../../../../resources/images/DisabledIcon.png';
import donateIcon from '../../../../../resources/images/DonateIconWhite.png';
import {EmojiTable} from "./EmojiTable";
enum Slot {
@@ -45,12 +45,12 @@ export class RadialMenu implements Layer {
private isCenterButtonEnabled = false
constructor(
private eventBus: EventBus,
private game: Game,
private transformHandler: TransformHandler,
private clientID: ClientID,
private emojiTable: EmojiTable
) { }
init() {
@@ -59,49 +59,6 @@ export class RadialMenu implements Layer {
this.createMenuElement();
}
private hideEmojiTable(): void {
const emojiTable: HTMLTableElement | null = document.getElementById('uniqueEmojiTable') as HTMLTableElement | null;
if (emojiTable instanceof HTMLTableElement) {
if (!emojiTable.classList.contains('hidden')) {
emojiTable.classList.add('hidden');
}
} else {
console.error('Emoji table not found');
}
}
private showEmojiTable(recipient: Player | typeof AllPlayers): void {
const emojiTable: HTMLTableElement | null = document.getElementById('uniqueEmojiTable') as HTMLTableElement | null;
if (emojiTable instanceof HTMLTableElement) {
emojiTable.classList.remove('hidden');
} else {
console.error('Emoji table not found');
}
this.setupEmojiButtons(recipient)
}
private setupEmojiButtons(recipient: Player | typeof AllPlayers) {
let emojiTable = document.getElementById('uniqueEmojiTable');
if (emojiTable) {
// Remove existing listeners
emojiTable.replaceWith(emojiTable.cloneNode(true));
emojiTable = document.getElementById('uniqueEmojiTable');
emojiTable.addEventListener('click', (event) => {
const emojiElement = event.target as HTMLElement;
if (emojiElement.classList.contains('emoji-button')) {
const emoji = emojiElement.textContent;
this.hideEmojiTable()
this.eventBus.emit(new SendEmojiIntentEvent(recipient, emoji))
}
});
} else {
console.error('Emoji table not found');
}
}
private createMenuElement() {
this.menuElement = d3.select(document.body)
.append('div')
@@ -227,7 +184,7 @@ export class RadialMenu implements Layer {
// Update logic if needed
}
render(context: CanvasRenderingContext2D) {
renderLayer(context: CanvasRenderingContext2D) {
// No need to render anything on the canvas
}
@@ -272,7 +229,11 @@ export class RadialMenu implements Layer {
const target = tile.owner() == myPlayer ? AllPlayers : (tile.owner() as Player)
if (myPlayer.canSendEmoji(target)) {
this.activateMenuElement(Slot.Emoji, "#ebe250", emojiIcon, () => {
this.showEmojiTable(target)
this.emojiTable.onEmojiClicked = (emoji: string) => {
this.emojiTable.hideTable()
this.eventBus.emit(new SendEmojiIntentEvent(target, emoji))
}
this.emojiTable.showTable()
})
}
}
@@ -387,7 +348,7 @@ export class RadialMenu implements Layer {
private onPointerUp(event: MouseUpEvent) {
this.hideRadialMenu()
this.hideEmojiTable()
this.emojiTable.hideTable()
}
private showRadialMenu(x: number, y: number) {
@@ -467,4 +428,4 @@ export class RadialMenu implements Layer {
.attr('fill', enabled ? 'white' : '#cccccc');
}, 25);
}
}
}
+33 -76
View File
@@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenFront (ALPHA)</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-16702609763">
</script>
@@ -26,19 +27,36 @@
fill="#ffffff" />
</svg>
</a>
<h1>OpenFront.io</h1>
<h2>(v0.6.5)</h2>
<div id="username-container">
<input type="text" id="username" placeholder="Enter your username">
<h1 class="text-9xl">OpenFront.io</h1>
<h2 class="text-6xl mb-2">(v0.6.5)</h2>
<div class="flex justify-center items-start">
<div class="w-full max-w-3xl p-4 space-y-4">
<username-input></username-input>
<!-- Button layout -->
<div class="flex space-x-4 max-w-xs mx-auto">
<!-- Single Player button -->
<button id="single-player"
class="flex-1 h-31 px-6 py-8 text-xl font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition duration-300 ease-in-out">
Single Player
</button>
<!-- Create and Join Lobby buttons stacked -->
<div class="flex-1 space-y-4">
<button id="create-lobby"
class="w-full h-12 px-4 py-4 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300 ease-in-out">
Create Lobby
</button>
<button id="join-lobby"
class="w-full h-12 px-4 py-4 text-sm font-medium text-blue-700 bg-blue-100 rounded-md hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300 ease-in-out">
Join Lobby
</button>
</div>
</div>
<h3 class="text-6xl mt-8 tracking-wide pt-8">Public Games</h3>
<public-lobby></public-lobby>
</div>
</div>
<div id="lobbies-container">
<button id="lobby-button" class="lobby-button">
<div id="lobby-name" class="lobby-name"></div>
<div id="lobby-timer" class="lobby-timer"></div>
<div id="player-count" class="player-count"></div>
</button>
</div>
<h3 id="next-game"> </h3>
</div>
<div id="customMenu">
@@ -49,71 +67,10 @@
<div id="app"></div>
<div id="radialMenu" class="radial-menu"></div>
<table id="uniqueEmojiTable" class="emoji-table hidden">
<tr>
<td><button class="emoji-button">😀</button></td>
<td><button class="emoji-button">😱</button></td>
<td><button class="emoji-button">🤩</button></td>
<td><button class="emoji-button">🎯</button></td>
<td><button class="emoji-button">🥺</button></td>
</tr>
<tr>
<td><button class="emoji-button">🪦</button></td>
<td><button class="emoji-button">👏</button></td>
<td><button class="emoji-button">🥉</button></td>
<td><button class="emoji-button">🥈</button></td>
<td><button class="emoji-button">🥇</button></td>
</tr>
<tr>
<td><button class="emoji-button">🤙</button></td>
<td><button class="emoji-button">🥰</button></td>
<td><button class="emoji-button">😇</button></td>
<td><button class="emoji-button">😊</button></td>
<td><button class="emoji-button">🔥</button></td>
</tr>
<tr>
<td><button class="emoji-button">💪</button></td>
<td><button class="emoji-button">🥳</button></td>
<td><button class="emoji-button">💀</button></td>
<td><button class="emoji-button">😭</button></td>
<td><button class="emoji-button">🤦‍♂️</button></td>
</tr>
<tr>
<td><button class="emoji-button">😎</button></td>
<td><button class="emoji-button">👎</button></td>
<td><button class="emoji-button">👍</button></td>
<td><button class="emoji-button">🥱</button></td>
<td><button class="emoji-button">💔</button></td>
</tr>
<tr>
<td><button class="emoji-button">❤️</button></td>
<td><button class="emoji-button">💰</button></td>
<td><button class="emoji-button">🤝</button></td>
<td><button class="emoji-button">🛡️</button></td>
<td><button class="emoji-button">💥</button></td>
</tr>
<tr>
<td><button class="emoji-button">🆘</button></td>
<td><button class="emoji-button">🕊️</button></td>
<td><button class="emoji-button">➡️</button></td>
<td><button class="emoji-button">⬅️</button></td>
<td><button class="emoji-button">↙️</button></td>
</tr>
<tr>
<td><button class="emoji-button">↖️</button></td>
<td><button class="emoji-button">↗️</button></td>
<td><button class="emoji-button">⬆️</button></td>
<td><button class="emoji-button">↘️</button></td>
<td><button class="emoji-button">⬇️</button></td>
</tr>
</table>
<single-player-modal></single-player-modal>
<emoji-table></emoji-table>
<leader-board></leader-board>
<style>
body {
visibility: hidden;
opacity: 0;
}
</style>
<script>
window.addEventListener('DOMContentLoaded', (event) => {
document.body.style.visibility = 'visible';
+4 -117
View File
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
touch-action: manipulation;
@@ -90,66 +94,6 @@ h3 {
display: block;
}
#lobbies-container {
display: flex;
justify-content: center;
}
.lobby-button {
width: 50%;
max-width: 300px;
max-height: 300px;
height: auto;
aspect-ratio: 1 / 1;
font-size: 18px;
font-weight: bold;
border: 3px solid #007bff;
background-color: rgba(0, 123, 255, 0.2);
color: #ffffff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
padding: 20px;
text-align: center;
box-sizing: border-box;
margin-bottom: 1em;
}
.lobby-button:hover {
background-color: rgba(0, 123, 255, 0.4);
transform: translateY(-5px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4);
}
.lobby-button.highlighted {
background-color: rgba(0, 123, 255, 0.6);
transform: translateY(-5px);
box-shadow: 0 6px 20px rgba(0, 123, 255, 0.6);
border-color: #ffffff;
}
.lobby-name {
font-size: 24px;
margin-bottom: 10px;
color: #000000;
}
.lobby-timer {
font-size: 20px;
margin-bottom: 10px;
color: #000000;
}
.player-count {
font-size: 20px;
color: #000000;
}
.joining-message {
font-size: 24px;
color: rgb(0, 0, 0);
@@ -402,64 +346,7 @@ h3 {
}
/* EMOJI Table */
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
/* Increased font size for larger emojis */
width: 80px;
/* Increased width */
height: 80px;
/* Increased height */
border: 1px solid #333;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
/* Slightly increased border radius */
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
/* Increased margin */
}
.emoji-button:hover {
background-color: #3A3A3A;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.emoji-table.hidden {
display: none;
}
@media (max-width: 600px) {
.emoji-button {
+2 -2
View File
@@ -9,10 +9,10 @@ export const devConfig = new class extends DefaultConfig {
return 40
}
gameCreationRate(): number {
return 2 * 1000
return 20 * 1000
}
lobbyLifetime(): number {
return 2 * 1000
return 20 * 1000
}
turnIntervalMs(): number {
return 100
+1 -1
View File
@@ -47,7 +47,7 @@ export class Executor {
)
} else if (intent.type == "spawn") {
return new SpawnExecution(
new PlayerInfo(intent.name, intent.playerType, intent.clientID, intent.playerID),
new PlayerInfo(intent.name.slice(0, 18), intent.playerType, intent.clientID, intent.playerID),
new Cell(intent.x, intent.y)
)
} else if (intent.type == "boat") {
+5
View File
@@ -11,6 +11,11 @@ export type Tick = number
export const AllPlayers = "AllPlayers" as const;
export enum GameMap {
World,
Europe
}
export class Nation {
constructor(
public readonly name: string,
+16 -6
View File
@@ -1,6 +1,13 @@
import {Cell, TerrainType} from './Game';
import binAsString from "!!binary-loader!../../../resources/maps/Europe.bin";
import worldMapInfo from "../../../resources/maps/Europe.json"
import {Cell, GameMap, TerrainType} from './Game';
import europeBin from "!!binary-loader!../../../resources/maps/Europe.bin";
import europeInfo from "../../../resources/maps/Europe.json"
import worldBin from "!!binary-loader!../../../resources/maps/WorldMap.bin";
import worldInfo from "../../../resources/maps/WorldMap.json"
const maps = new Map()
.set(GameMap.World, {bin: worldBin, info: worldInfo})
.set(GameMap.Europe, {bin: europeBin, info: europeInfo});
export interface NationMap {
name: string;
@@ -44,10 +51,13 @@ export class Terrain {
constructor(public type: TerrainType) { }
}
export async function loadTerrainMap(): Promise<TerrainMap> {
export async function loadTerrainMap(map: GameMap): Promise<TerrainMap> {
const mapData = maps.get(map)
// Simulate an asynchronous file load
const fileData = await new Promise<string>((resolve) => {
setTimeout(() => resolve(binAsString), 100);
setTimeout(() => resolve(mapData.bin), 100);
});
console.log(`Loaded data length: ${fileData.length} bytes`);
@@ -103,7 +113,7 @@ export async function loadTerrainMap(): Promise<TerrainMap> {
}
}
return new TerrainMap(terrain, numLand, worldMapInfo);
return new TerrainMap(terrain, numLand, mapData.info);
}
function logBinaryAsAscii(data: string, length: number = 8) {
+2 -3
View File
@@ -1,5 +1,4 @@
import PImage from 'pureimage';
import path from 'path';
import {decodePNGFromStream} from 'pureimage'; import path from 'path';
import fs from 'fs/promises';
import {createReadStream} from 'fs';
import {fileURLToPath} from 'url';
@@ -47,7 +46,7 @@ export async function loadTerrainMap(): Promise<void> {
const imagePath = path.resolve(__dirname, '..', '..', 'resources', 'maps', mapName + '.png');
const readStream = createReadStream(imagePath);
const img = await PImage.decodePNGFromStream(readStream);
const img = await decodePNGFromStream(readStream);
console.log('Image loaded successfully');
console.log('Image dimensions:', img.width, 'x', img.height);
+2 -2
View File
@@ -1,10 +1,10 @@
import {GamePhase, GameServer} from "./GameServer";
import {Config} from "../core/configuration/Config";
import {PseudoRandom} from "../core/PseudoRandom";
import WebSocket from 'ws';
import {ClientID, GameID} from "../core/Schemas";
import {Client} from "./Client";
import {v4 as uuidv4} from 'uuid';
import {Client} from "./Client";
import {GamePhase, GameServer} from "./GameServer";
-21
View File
@@ -1,21 +0,0 @@
import {ClientID} from "../core/Schemas";
import {Client} from "./Client";
export class Lobby {
public clients: Map<ClientID, Client> = new Map()
private startGameTs: number
constructor(public readonly id: string, durationMs: number) {
this.startGameTs = Date.now() + durationMs
}
public addClient(client: Client) {
this.clients.set(client.id, client)
}
public isExpired(now: number): boolean {
return now > this.startGameTs
}
}
+2 -2
View File
@@ -4,11 +4,11 @@ import {WebSocketServer} from 'ws';
import path from 'path';
import {fileURLToPath} from 'url';
import {GameManager} from './GameManager';
import {Client} from './Client';
import {ClientMessage, ClientMessageSchema} from '../core/Schemas';
import {GamePhase} from './GameServer';
import {getConfig} from '../core/configuration/Config';
import {LogSeverity, slog} from './StructuredLog';
import {Client} from './Client';
import {GamePhase} from './GameServer';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
+3 -1
View File
@@ -6,7 +6,9 @@
"moduleResolution": "node",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
"esModuleInterop": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
},
"include": [
"src/**/*",
+1 -1
View File
@@ -10,7 +10,7 @@ export default (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: './src/client/Client.ts',
entry: './src/client/Main.ts',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'out'),