mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 06:34:15 +00:00
First Commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import {TerrainMap} from "../core/Game";
|
||||
import {ServerMessage, ServerMessageSchema} from "../core/Schemas";
|
||||
import {defaultSettings} from "../core/Settings";
|
||||
import {loadTerrainMap} from "../core/TerrainMapLoader";
|
||||
import {generateUniqueID} from "../core/Util";
|
||||
import {ClientGame, createClientGame} from "./ClientGame";
|
||||
import {v4 as uuidv4} from 'uuid';
|
||||
|
||||
// import WebSocket from 'ws';
|
||||
|
||||
class Client {
|
||||
private hasJoined = false
|
||||
|
||||
private startButton: HTMLButtonElement | null;
|
||||
private socket: WebSocket | null = null;
|
||||
private terrainMap: Promise<TerrainMap>
|
||||
private game: ClientGame
|
||||
|
||||
private lobbiesContainer: HTMLElement | null;
|
||||
private lobbiesInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startButton = document.getElementById('startButton') as HTMLButtonElement | null;
|
||||
this.lobbiesContainer = document.getElementById('lobbies-container');
|
||||
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.terrainMap = loadTerrainMap()
|
||||
this.startLobbyPolling()
|
||||
}
|
||||
|
||||
private startLobbyPolling(): void {
|
||||
this.fetchAndUpdateLobbies(); // Fetch immediately on start
|
||||
this.lobbiesInterval = setInterval(() => this.fetchAndUpdateLobbies(), 1000);
|
||||
}
|
||||
|
||||
private async fetchAndUpdateLobbies(): Promise<void> {
|
||||
try {
|
||||
const data = await this.fetchLobbies();
|
||||
this.updateLobbiesDisplay(data.lobbies);
|
||||
} catch (error) {
|
||||
console.error('Error fetching and updating lobbies:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateLobbiesDisplay(lobbies: Array<{id: string}>): void {
|
||||
if (!this.lobbiesContainer) return;
|
||||
|
||||
this.lobbiesContainer.innerHTML = ''; // Clear existing lobbies
|
||||
|
||||
lobbies.forEach(lobby => {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = `Join Lobby ${lobby.id}`;
|
||||
button.onclick = () => this.joinLobby(lobby.id);
|
||||
this.lobbiesContainer.appendChild(button);
|
||||
});
|
||||
|
||||
// Join first lobby
|
||||
if (!this.hasJoined && lobbies.length > 0) {
|
||||
this.hasJoined = true
|
||||
console.log(`joining lobby ${lobbies[0].id}`)
|
||||
this.joinLobby(lobbies[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLobbies() {
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error('Error fetching lobbies:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobby(lobbyID: string) {
|
||||
this.terrainMap.then((map) => {
|
||||
this.game = createClientGame(uuidv4().slice(0, 4), generateUniqueID(), lobbyID, defaultSettings, map)
|
||||
this.game.joinLobby()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the client when the DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Client().initialize();
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import {Executor} from "../core/execution/Executor";
|
||||
import {Cell, ClientID, MutableGame, LobbyID, PlayerEvent, PlayerID, PlayerInfo, MutablePlayer, TerrainMap, TileEvent, Player, Game, BoatEvent} from "../core/Game";
|
||||
import {createGame} from "../core/GameImpl";
|
||||
import {Ticker, TickEvent} from "../core/Ticker";
|
||||
import {EventBus} from "../core/EventBus";
|
||||
import {Settings} from "../core/Settings";
|
||||
import {GameRenderer} from "./GameRenderer";
|
||||
import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler"
|
||||
import {ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas";
|
||||
import {AttackIntent, Intent, SpawnIntent} from "../core/Schemas";
|
||||
|
||||
|
||||
|
||||
export function createClientGame(name: string, clientID: ClientID, lobbyID: LobbyID, settings: Settings, terrainMap: TerrainMap): ClientGame {
|
||||
let eventBus = new EventBus()
|
||||
let gs = createGame(terrainMap, eventBus)
|
||||
let gameRenderer = new GameRenderer(gs, settings.theme(), document.createElement("canvas"))
|
||||
let ticker = new Ticker(settings.tickIntervalMs(), eventBus)
|
||||
|
||||
return new ClientGame(
|
||||
name,
|
||||
clientID,
|
||||
lobbyID,
|
||||
ticker,
|
||||
eventBus,
|
||||
gs,
|
||||
gameRenderer,
|
||||
new InputHandler(eventBus),
|
||||
new Executor(gs)
|
||||
)
|
||||
}
|
||||
|
||||
export class ClientGame {
|
||||
|
||||
private myPlayer: Player
|
||||
private turns: Turn[] = []
|
||||
private socket: WebSocket
|
||||
private started = false
|
||||
|
||||
private ticksPerTurn = 1
|
||||
|
||||
private ticksThisTurn = 0
|
||||
private currTurn = 0
|
||||
|
||||
constructor(
|
||||
private playerName: string,
|
||||
private id: ClientID,
|
||||
private lobbyID: LobbyID,
|
||||
private ticker: Ticker,
|
||||
private eventBus: EventBus,
|
||||
private gs: Game,
|
||||
private renderer: GameRenderer,
|
||||
private input: InputHandler,
|
||||
private executor: Executor
|
||||
) { }
|
||||
|
||||
public joinLobby() {
|
||||
this.socket = new WebSocket(`ws://localhost:3000`)
|
||||
this.socket.onopen = () => {
|
||||
console.log('Connected to game server!');
|
||||
this.socket.send(
|
||||
JSON.stringify(
|
||||
ClientJoinMessageSchema.parse({
|
||||
type: "join",
|
||||
lobbyID: this.lobbyID,
|
||||
clientID: this.id
|
||||
})
|
||||
)
|
||||
)
|
||||
};
|
||||
this.socket.onmessage = (event: MessageEvent) => {
|
||||
const message: ServerMessage = ServerMessageSchema.parse(JSON.parse(event.data))
|
||||
if (message.type == "start") {
|
||||
console.log("starting game!")
|
||||
this.start()
|
||||
}
|
||||
if (message.type == "turn") {
|
||||
this.addTurn(message.turn)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.started = true
|
||||
console.log('starting game!')
|
||||
// TODO: make each class do this, or maybe have client intercept all requests?
|
||||
//this.eventBus.on(TickEvent, (e) => this.tick(e))
|
||||
this.eventBus.on(TileEvent, (e) => this.renderer.tileUpdate(e))
|
||||
this.eventBus.on(PlayerEvent, (e) => this.playerEvent(e))
|
||||
this.eventBus.on(BoatEvent, (e) => this.renderer.boatEvent(e))
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e))
|
||||
this.eventBus.on(ZoomEvent, (e) => this.renderer.onZoom(e))
|
||||
this.eventBus.on(DragEvent, (e) => this.renderer.onMove(e))
|
||||
|
||||
this.renderer.initialize()
|
||||
this.input.initialize()
|
||||
this.executor.spawnBots(500)
|
||||
|
||||
|
||||
setInterval(() => this.tick(), 10);
|
||||
}
|
||||
|
||||
public addTurn(turn: Turn): void {
|
||||
this.turns.push(turn)
|
||||
}
|
||||
|
||||
public tick() {
|
||||
if (this.ticksThisTurn >= this.ticksPerTurn) {
|
||||
if (this.currTurn >= this.turns.length) {
|
||||
return
|
||||
}
|
||||
this.executor.addTurn(this.turns[this.currTurn])
|
||||
this.currTurn++
|
||||
this.ticksThisTurn = 0
|
||||
}
|
||||
this.ticksThisTurn++
|
||||
console.log('client ticking')
|
||||
this.gs.tick()
|
||||
}
|
||||
|
||||
private playerEvent(event: PlayerEvent) {
|
||||
console.log('received new player event!')
|
||||
// TODO: what if multiple players has same name
|
||||
if (event.player.info().name == this.playerName) {
|
||||
console.log('setting name')
|
||||
this.myPlayer = event.player
|
||||
}
|
||||
this.renderer.playerEvent(event)
|
||||
}
|
||||
|
||||
private inputEvent(event: MouseDownEvent) {
|
||||
const cell = this.renderer.screenToWorldCoordinates(event.x, event.y)
|
||||
const tile = this.gs.tile(cell)
|
||||
if (!tile.hasOwner() && !this.hasSpawned()) {
|
||||
this.sendSpawnIntent(cell)
|
||||
return
|
||||
}
|
||||
if (!this.hasSpawned()) {
|
||||
return
|
||||
}
|
||||
|
||||
const owner = tile.owner()
|
||||
const targetID = owner.isPlayer() ? owner.id() : null
|
||||
if (tile.owner() != this.myPlayer) {
|
||||
if (this.myPlayer.sharesBorderWith(tile.owner())) {
|
||||
this.sendAttackIntent(targetID, cell)
|
||||
} else {
|
||||
// TODO verify on ocean
|
||||
console.log('going to send boat')
|
||||
this.sendBoatAttackIntent(targetID, cell)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private hasSpawned(): boolean {
|
||||
return this.myPlayer != null
|
||||
}
|
||||
|
||||
private sendSpawnIntent(cell: Cell) {
|
||||
const spawn = JSON.stringify(
|
||||
ClientIntentMessageSchema.parse({
|
||||
type: "intent",
|
||||
clientID: this.id,
|
||||
intent: {
|
||||
type: "spawn",
|
||||
name: this.playerName,
|
||||
isBot: false,
|
||||
x: cell.x,
|
||||
y: cell.y
|
||||
}
|
||||
})
|
||||
)
|
||||
console.log(spawn)
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
console.log(`seding spawn intent: ${spawn}`)
|
||||
this.socket.send(spawn)
|
||||
} else {
|
||||
console.log('WebSocket is not open. Current state:', this.socket.readyState);
|
||||
}
|
||||
}
|
||||
|
||||
private sendAttackIntent(targetID: PlayerID, cell: Cell) {
|
||||
const attack = JSON.stringify(
|
||||
ClientIntentMessageSchema.parse({
|
||||
type: "intent",
|
||||
clientID: this.id,
|
||||
intent: {
|
||||
type: "attack",
|
||||
attackerID: this.myPlayer.id(),
|
||||
targetID: targetID,
|
||||
troops: 2000,
|
||||
targetX: cell.x,
|
||||
targetY: cell.y
|
||||
}
|
||||
})
|
||||
)
|
||||
console.log(attack)
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
console.log(`sending attack intent: ${attack}`)
|
||||
this.socket.send(attack)
|
||||
} else {
|
||||
console.log('WebSocket is not open. Current state:', this.socket.readyState);
|
||||
}
|
||||
}
|
||||
|
||||
private sendBoatAttackIntent(targetID: PlayerID, cell: Cell) {
|
||||
const attack = JSON.stringify(
|
||||
ClientIntentMessageSchema.parse({
|
||||
type: "intent",
|
||||
clientID: this.id,
|
||||
intent: {
|
||||
type: "boat",
|
||||
attackerID: this.myPlayer.id(),
|
||||
targetID: targetID,
|
||||
troops: 2000,
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
}
|
||||
})
|
||||
)
|
||||
console.log(attack)
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
console.log(`sending boat attack intent: ${attack}`)
|
||||
this.socket.send(attack)
|
||||
} else {
|
||||
console.log('WebSocket is not open. Current state:', this.socket.readyState);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import {Colord} from "colord";
|
||||
import {Cell, MutableGame, Game, PlayerEvent, Tile, TileEvent, Player, Execution, BoatEvent} from "../core/Game";
|
||||
import {Theme} from "../core/Settings";
|
||||
import {DragEvent, ZoomEvent} from "./InputHandler";
|
||||
import {calculateBoundingBox, placeName} from "./NameBoxCalculator";
|
||||
import {PseudoRandom} from "../core/PseudoRandom";
|
||||
import {BoatAttackExecution} from "../core/execution/BoatAttackExecution";
|
||||
|
||||
class NameRender {
|
||||
constructor(public lastRendered: number, public location: Cell, public fontSize: number) { }
|
||||
}
|
||||
|
||||
export class GameRenderer {
|
||||
|
||||
private scale: number = .8
|
||||
private offsetX: number = 0
|
||||
private offsetY: number = 100
|
||||
|
||||
private context: CanvasRenderingContext2D
|
||||
|
||||
private imageData: ImageData
|
||||
|
||||
private nameRenders: Map<Player, NameRender> = new Map()
|
||||
|
||||
private rand = new PseudoRandom(10)
|
||||
|
||||
constructor(private gs: Game, private theme: Theme, private canvas: HTMLCanvasElement) {
|
||||
this.context = canvas.getContext("2d")
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
|
||||
// Set canvas style to fill the screen
|
||||
this.canvas.style.position = 'fixed';
|
||||
this.canvas.style.left = '0';
|
||||
this.canvas.style.top = '0';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
|
||||
this.imageData = this.context.getImageData(0, 0, this.gs.width(), this.gs.height())
|
||||
this.initImageData()
|
||||
|
||||
|
||||
document.body.appendChild(this.canvas);
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
this.resizeCanvas();
|
||||
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
}
|
||||
|
||||
initImageData() {
|
||||
this.gs.forEachTile((tile) => {
|
||||
//const color = this.theme.terrainColor(tile.terrain())
|
||||
this.paintTile(tile)
|
||||
})
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
//this.redraw()
|
||||
}
|
||||
|
||||
renderGame() {
|
||||
// Clear the canvas
|
||||
this.context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.context.clearRect(0, 0, this.gs.width(), this.gs.height());
|
||||
|
||||
// Set background
|
||||
this.context.fillStyle = this.theme.backgroundColor().toHex();
|
||||
this.context.fillRect(0, 0, this.gs.width(), this.gs.height());
|
||||
|
||||
// Create a temporary canvas for the game content
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
tempCanvas.width = this.gs.width();
|
||||
tempCanvas.height = this.gs.height();
|
||||
|
||||
// Put the ImageData on the temp canvas
|
||||
tempCtx.putImageData(this.imageData, 0, 0);
|
||||
|
||||
// Disable image smoothing for pixelated effect
|
||||
if (this.scale > 3) {
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
} else {
|
||||
this.context.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
// Apply zoom and pan
|
||||
this.context.setTransform(
|
||||
this.scale,
|
||||
0,
|
||||
0,
|
||||
this.scale,
|
||||
this.gs.width() / 2 - this.offsetX * this.scale,
|
||||
this.gs.height() / 2 - this.offsetY * this.scale
|
||||
);
|
||||
|
||||
// Draw the game content from the temp canvas
|
||||
this.context.drawImage(
|
||||
tempCanvas,
|
||||
-this.gs.width() / 2,
|
||||
-this.gs.height() / 2,
|
||||
this.gs.width(),
|
||||
this.gs.height()
|
||||
);
|
||||
|
||||
let numCalcs = 0
|
||||
for (const player of this.gs.players()) {
|
||||
if (numCalcs < 50 && this.maybeRecalculatePlayerInfo(player)) {
|
||||
numCalcs++
|
||||
}
|
||||
this.renderPlayerInfo(player)
|
||||
}
|
||||
|
||||
// const paths = this.gs.executions().map(e => e as Execution).filter(e => e instanceof BoatAttackExecution).map(e => e as BoatAttackExecution).filter(e => e.path != null).map(e => e.path)
|
||||
// paths.forEach(p => {
|
||||
// p.forEach(t => {
|
||||
// this.paintCell(t.cell(), new Colord({r: 255, g: 255, b: 255}))
|
||||
// })
|
||||
// })
|
||||
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
}
|
||||
|
||||
maybeRecalculatePlayerInfo(player: Player): boolean {
|
||||
if (!this.nameRenders.has(player)) {
|
||||
this.nameRenders.set(player, new NameRender(0, null, null))
|
||||
}
|
||||
|
||||
const render = this.nameRenders.get(player)
|
||||
|
||||
let wasUpdated = false
|
||||
|
||||
if (Date.now() - render.lastRendered > 1000) {
|
||||
render.lastRendered = Date.now() + this.rand.nextInt(0, 100)
|
||||
wasUpdated = true
|
||||
|
||||
const box = calculateBoundingBox(player)
|
||||
const centerX = box.min.x + ((box.max.x - box.min.x) / 2)
|
||||
const centerY = box.min.y + ((box.max.y - box.min.y) / 2)
|
||||
render.location = new Cell(centerX, centerY)
|
||||
render.fontSize = Math.max(Math.min(box.max.x - box.min.x, box.max.y - box.min.y) / player.info().name.length / 2, 1)
|
||||
}
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
renderPlayerInfo(player: Player) {
|
||||
if (!player.isAlive()) {
|
||||
return
|
||||
}
|
||||
if (!this.nameRenders.has(player)) {
|
||||
return
|
||||
}
|
||||
|
||||
const render = this.nameRenders.get(player)
|
||||
|
||||
this.context.font = `${render.fontSize}px Arial`;
|
||||
this.context.fillStyle = this.theme.playerInfoColor(player.id()).toHex();
|
||||
this.context.textAlign = 'center';
|
||||
this.context.textBaseline = 'middle';
|
||||
|
||||
const nameCenterX = render.location.x - this.gs.width() / 2
|
||||
const nameCenterY = render.location.y - this.gs.height() / 2
|
||||
this.context.fillText(player.info().name, nameCenterX, nameCenterY - render.fontSize / 2);
|
||||
this.context.fillText(String(Math.floor(player.troops())), nameCenterX, nameCenterY + render.fontSize);
|
||||
}
|
||||
|
||||
tileUpdate(event: TileEvent) {
|
||||
this.paintTile(event.tile)
|
||||
this.gs.neighbors(event.tile.cell()).forEach(c => this.paintTile(this.gs.tile(c)))
|
||||
}
|
||||
|
||||
playerEvent(event: PlayerEvent) {
|
||||
}
|
||||
|
||||
boatEvent(event: BoatEvent) {
|
||||
this.paintCell(event.boat.cell(), new Colord({r: 255, g: 255, b: 255}))
|
||||
this.gs.neighbors(event.boat.cell()).map(c => this.gs.tile(c)).forEach(t => this.paintTile(t))
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
this.canvas.width = Math.ceil(width / window.devicePixelRatio);
|
||||
this.canvas.height = Math.ceil(height / window.devicePixelRatio);
|
||||
}
|
||||
|
||||
paintTile(tile: Tile) {
|
||||
// const index = (tile.cell().y * this.gs.width()) + tile.cell().x
|
||||
// color.toRGB().writeToBuffer(this.imageData.data, index * 4)
|
||||
let terrainColor = this.theme.terrainColor(tile.terrain())
|
||||
this.paintCell(tile.cell(), terrainColor)
|
||||
const owner = tile.owner()
|
||||
if (owner.isPlayer()) {
|
||||
if (tile.isBorder()) {
|
||||
this.paintCell(tile.cell(), this.theme.borderColor(owner.id()))
|
||||
} else {
|
||||
this.paintCell(tile.cell(), this.theme.territoryColor(owner.id()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paintCell(cell: Cell, color: Colord) {
|
||||
const index = (cell.y * this.gs.width()) + cell.x
|
||||
const offset = index * 4
|
||||
this.imageData.data[offset] = color.rgba.r;
|
||||
this.imageData.data[offset + 1] = color.rgba.g;
|
||||
this.imageData.data[offset + 2] = color.rgba.b;
|
||||
this.imageData.data[offset + 3] = color.rgba.a * 255 | 0
|
||||
}
|
||||
|
||||
onZoom(event: ZoomEvent) {
|
||||
const oldScale = this.scale;
|
||||
const zoomFactor = 1 + event.delta / 600;
|
||||
this.scale *= zoomFactor;
|
||||
|
||||
// Clamp the scale to prevent extreme zooming
|
||||
this.scale = Math.max(0.1, Math.min(10, this.scale));
|
||||
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
const canvasX = event.x - canvasRect.left;
|
||||
const canvasY = event.y - canvasRect.top;
|
||||
|
||||
// Calculate the world point we want to zoom towards
|
||||
const zoomPointX = (canvasX - this.gs.width() / 2) / oldScale + this.offsetX;
|
||||
const zoomPointY = (canvasY - this.gs.height() / 2) / oldScale + this.offsetY;
|
||||
|
||||
// Adjust the offset
|
||||
this.offsetX = zoomPointX - (canvasX - this.gs.width() / 2) / this.scale;
|
||||
this.offsetY = zoomPointY - (canvasY - this.gs.height() / 2) / this.scale;
|
||||
}
|
||||
|
||||
onMove(event: DragEvent) {
|
||||
this.offsetX -= event.deltaX / this.scale;
|
||||
this.offsetY -= event.deltaY / this.scale;
|
||||
}
|
||||
|
||||
|
||||
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
|
||||
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
const canvasX = screenX - canvasRect.left;
|
||||
const canvasY = screenY - canvasRect.top;
|
||||
|
||||
// Calculate the world point we want to zoom towards
|
||||
const centerX = (canvasX - this.gs.width() / 2) / this.scale + this.offsetX;
|
||||
const centerY = (canvasY - this.gs.height() / 2) / this.scale + this.offsetY;
|
||||
|
||||
const gameX = centerX + this.gs.width() / 2
|
||||
const gameY = centerY + this.gs.height() / 2
|
||||
|
||||
|
||||
console.log(`zoom point ${centerX} ${centerY}`)
|
||||
console.log(`Current scale: ${this.scale}`);
|
||||
console.log(`Current offset: ${this.offsetX}, ${this.offsetY}`);
|
||||
|
||||
return new Cell(Math.floor(gameX), Math.floor(gameY));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {EventBus, GameEvent} from "../core/EventBus";
|
||||
import {Cell} from "../core/Game";
|
||||
|
||||
export class MouseUpEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
) { }
|
||||
}
|
||||
|
||||
export class MouseDownEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
) { }
|
||||
}
|
||||
|
||||
export class ZoomEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
public readonly delta: number
|
||||
) { }
|
||||
}
|
||||
|
||||
export class DragEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly deltaX: number,
|
||||
public readonly deltaY: number,
|
||||
) { }
|
||||
}
|
||||
|
||||
export class InputHandler {
|
||||
|
||||
private lastMouseDownX: number = 0
|
||||
private lastMouseDownY: number
|
||||
|
||||
private isMouseDown: boolean = false;
|
||||
private lastMouseX: number = 0;
|
||||
private lastMouseY: number = 0;
|
||||
|
||||
constructor(private eventBus: EventBus) { }
|
||||
|
||||
initialize() {
|
||||
document.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||
document.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||
document.addEventListener("wheel", (e) => this.onScroll(e), {passive: false});
|
||||
document.addEventListener('mousedown', this.onMouseDown.bind(this));
|
||||
document.addEventListener('mousemove', this.onMouseMove.bind(this));
|
||||
document.addEventListener('mouseup', this.onMouseUp.bind(this));
|
||||
document.addEventListener('mouseleave', this.onMouseUp.bind(this))
|
||||
}
|
||||
|
||||
onPointerDown(event: PointerEvent) {
|
||||
this.lastMouseDownX = event.x
|
||||
this.lastMouseDownY = event.y
|
||||
this.eventBus.emit(new MouseDownEvent(event.x, event.y))
|
||||
}
|
||||
|
||||
onPointerUp(event: PointerEvent) {
|
||||
const dist = Math.abs(event.x - this.lastMouseDownX) + Math.abs(event.y - this.lastMouseDownY);
|
||||
if (dist < 10) {
|
||||
this.eventBus.emit(new MouseUpEvent(event.x, event.y))
|
||||
}
|
||||
}
|
||||
|
||||
private onScroll(event: WheelEvent) {
|
||||
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY))
|
||||
}
|
||||
|
||||
private onMouseDown(event: MouseEvent) {
|
||||
this.isMouseDown = true;
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
}
|
||||
|
||||
private onMouseMove(event: MouseEvent) {
|
||||
if (!this.isMouseDown) return;
|
||||
|
||||
const deltaX = event.clientX - this.lastMouseX;
|
||||
const deltaY = event.clientY - this.lastMouseY;
|
||||
|
||||
this.eventBus.emit(new DragEvent(deltaX, deltaY))
|
||||
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
}
|
||||
|
||||
private onMouseUp(event: MouseEvent) {
|
||||
this.isMouseDown = false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {Game, Player, Tile, Cell} from '../core/Game';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function placeName(game: Game, player: Player): [position: Cell, fontSize: number] {
|
||||
const boundingBox = calculateBoundingBox(player);
|
||||
const grid = createGrid(game, player, boundingBox);
|
||||
const largestRectangle = findLargestInscribedRectangle(grid);
|
||||
|
||||
const center = new Cell(
|
||||
largestRectangle.x + largestRectangle.width / 2,
|
||||
largestRectangle.y + largestRectangle.height / 2,
|
||||
)
|
||||
|
||||
const fontSize = calculateFontSize(largestRectangle, player.info().name);
|
||||
|
||||
return [center, fontSize]
|
||||
}
|
||||
|
||||
export function calculateBoundingBox(player: Player): {min: Point; max: Point} {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
player.borderTiles().forEach((tile: Tile) => {
|
||||
const cell = tile.cell();
|
||||
minX = Math.min(minX, cell.x);
|
||||
minY = Math.min(minY, cell.y);
|
||||
maxX = Math.max(maxX, cell.x);
|
||||
maxY = Math.max(maxY, cell.y);
|
||||
});
|
||||
|
||||
return {min: {x: minX, y: minY}, max: {x: maxX, y: maxY}};
|
||||
}
|
||||
|
||||
export function createGrid(game: Game, player: Player, boundingBox: {min: Point; max: Point}): boolean[][] {
|
||||
const width = boundingBox.max.x - boundingBox.min.x + 1;
|
||||
const height = boundingBox.max.y - boundingBox.min.y + 1;
|
||||
const grid: boolean[][] = Array(width).fill(null).map(() => Array(height).fill(false));
|
||||
|
||||
for (let y = boundingBox.min.y; y <= boundingBox.max.y; y++) {
|
||||
for (let x = boundingBox.min.x; x <= boundingBox.max.x; x++) {
|
||||
const cell = new Cell(x, y);
|
||||
if (game.isOnMap(cell)) {
|
||||
const tile = game.tile(cell);
|
||||
grid[x - boundingBox.min.x][y - boundingBox.min.y] = tile.owner() === player;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function findLargestInscribedRectangle(grid: boolean[][]): Rectangle {
|
||||
const rows = grid[0].length;
|
||||
const cols = grid.length;
|
||||
const heights: number[] = new Array(cols).fill(0);
|
||||
let largestRect: Rectangle = {x: 0, y: 0, width: 0, height: 0};
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
if (grid[col][row]) {
|
||||
heights[row]++;
|
||||
} else {
|
||||
heights[row] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const rectForRow = largestRectangleInHistogram(heights);
|
||||
|
||||
if (rectForRow.width * rectForRow.height > largestRect.width * largestRect.height) {
|
||||
largestRect = {
|
||||
x: rectForRow.x,
|
||||
y: row - rectForRow.height + 1,
|
||||
width: rectForRow.width,
|
||||
height: rectForRow.height
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return largestRect;
|
||||
}
|
||||
|
||||
export function largestRectangleInHistogram(widths: number[]): Rectangle {
|
||||
const stack: number[] = [];
|
||||
let maxArea = 0;
|
||||
let largestRect: Rectangle = {x: 0, y: 0, width: 0, height: 0};
|
||||
|
||||
for (let i = 0; i <= widths.length; i++) {
|
||||
const h = i === widths.length ? 0 : widths[i];
|
||||
|
||||
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
|
||||
const height = widths[stack.pop()!];
|
||||
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
|
||||
|
||||
if (height * width > maxArea) {
|
||||
maxArea = height * width;
|
||||
largestRect = {
|
||||
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
|
||||
y: 0,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
stack.push(i);
|
||||
}
|
||||
|
||||
return largestRect;
|
||||
}
|
||||
|
||||
export function calculateFontSize(rectangle: Rectangle, name: string): number {
|
||||
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
|
||||
const aspectRatio = name.length; // Assuming width:height ratio of 2:1 for each character
|
||||
const widthConstrained = rectangle.width / name.length;
|
||||
const heightConstrained = rectangle.height / 2;
|
||||
return Math.min(widthConstrained, heightConstrained);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warfront</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Warfront</h1>
|
||||
<button id="startButton">Start Game</button>
|
||||
<div id="game-setup">
|
||||
<div id="lobbies-container">
|
||||
<h2>Available Lobbies</h2>
|
||||
<!-- Lobby buttons will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user