diff --git a/assets/crown.png b/assets/crown.png index 3831d0b..480f305 100644 Binary files a/assets/crown.png and b/assets/crown.png differ diff --git a/assets/default_avatar.png b/assets/default_avatar.png index 97f6f09..4f076a1 100644 Binary files a/assets/default_avatar.png and b/assets/default_avatar.png differ diff --git a/package.json b/package.json index dcf9abf..ba06ea6 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,12 @@ "type": "module", "scripts": { "dev": "vite --host", - "build": "tsc && vite build", + "build": "tsc && vite build && cp -r assets/ dist/", "preview": "vite preview" }, "dependencies": { + "@types/color-convert": "^2.0.3", + "color-convert": "^2.0.1", "phaser": "^3.70.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2" diff --git a/src/board/board.ts b/src/board/board.ts index c5aca86..c4b74a3 100644 --- a/src/board/board.ts +++ b/src/board/board.ts @@ -81,7 +81,7 @@ export default class GameBoard extends Phaser.GameObjects.Container { } const coords = GameBoard.getCoordForPos(startPos); - const color = getColorFromUUID(this.aznopoly.room.getPlayerName(uuid)); + const color = getColorFromUUID(uuid); const player = { gameObject: new Phaser.GameObjects.Rectangle(this.scene, coords.x * this.TILE_SIZE, coords.y * this.TILE_SIZE, PLAYER_SIZE, PLAYER_SIZE, color), position: startPos, diff --git a/src/debug-util.ts b/src/debug-util.ts new file mode 100644 index 0000000..61e611e --- /dev/null +++ b/src/debug-util.ts @@ -0,0 +1,19 @@ +import AzNopolyClient from "./client"; +import AzNopolyGame from "./game"; +import Room from "./room"; + +export function mock(aznopoly: AzNopolyGame) { + let game = aznopoly as any; + game._client = { + id: "1111-2222-3333-4444", + sendPacket: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + game._room = { + connectedPlayerIds: ["1111-2222-3333-4444"], + host: "1111-2222-3333-4444", + }; + game._name = "mockius maximus"; + +} \ No newline at end of file diff --git a/src/game.ts b/src/game.ts index 656c537..0d4962d 100644 --- a/src/game.ts +++ b/src/game.ts @@ -35,10 +35,27 @@ export default class AzNopolyGame { return this._room; } + public get players(): string[] { + return this.room.connectedPlayerIds; + } + public get isHost(): boolean { return this.client.id == this.room.host; } + public broadcastPacket(packet: {type: string, data: any}) { + this.client.sendPacket(packet); + } + + public addPacketListener(type: string, listener: EventListener) { + this.client.addEventListener(type, listener); + return listener; + } + + public removePacketListener(type: string, listener: EventListener) { + this.client.removeEventListener(type, listener); + } + public isPlayerHost(uuid: string) { return this.room.host == uuid; } diff --git a/src/main.ts b/src/main.ts index 08bf7f3..167fc3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import Phaser from 'phaser'; import TitleScene from './scene/title-scene'; -import GameScene from './scene/game-scene'; +import BoardScene from './scene/game-scene'; import LobbyScene from './scene/lobby-scene'; import { SimonSaysScene } from './scene/minigame/simon-says-scene'; import AzNopolyGame from './game'; +import { RoombaScene } from './scene/minigame/roomba-scene'; export const WIDTH = 1280; export const HEIGHT = 720; @@ -14,6 +15,13 @@ window.onload = () => { width: WIDTH, height: HEIGHT, parent: 'app', + physics: { + default: 'arcade', + arcade: { + gravity: { y: 0 }, + debug: true + } + }, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH @@ -24,13 +32,19 @@ window.onload = () => { }); const aznopoly = new AzNopolyGame(); + //await mock(aznopoly); + //console.log("mocked"); game.scene.add('title', new TitleScene(aznopoly)); game.scene.add('lobby', new LobbyScene(aznopoly)); - game.scene.add('game', new GameScene(aznopoly)); + game.scene.add('game', new BoardScene(aznopoly)); //Minigames game.scene.add('minigame-simon-says', new SimonSaysScene(aznopoly)) + game.scene.add('minigame-roomba', new RoombaScene(aznopoly)) + + //game.scene.start('minigame-roomba'); + game.scene.start('title'); game.scene.start('title'); Object.assign(window, { game }); diff --git a/src/minigame/color-progressbar.ts b/src/minigame/color-progressbar.ts new file mode 100644 index 0000000..9d76683 --- /dev/null +++ b/src/minigame/color-progressbar.ts @@ -0,0 +1,45 @@ + + +export default class ColorProgressBar extends Phaser.GameObjects.Container { + + private graphics: Phaser.GameObjects.Graphics; + + private colors: Map = new Map(); + private barWidth: number; + private barHeight: number; + + constructor(scene: Phaser.Scene, x: number, y: number, width: number, height: number) { + super(scene, x, y); + this.barWidth = width; + this.barHeight = height; + + this.graphics = new Phaser.GameObjects.Graphics(scene); + this.drawBar(); + + this.add(this.graphics); + } + + public setColors(colors: Map) { + this.colors = colors; + this.drawBar(); + } + + private drawBar() { + this.graphics.clear(); + + let progress = 0; + for (const[color, percentage] of this.colors.entries()) { + this.graphics.fillStyle(color); + this.graphics.fillRect(this.barWidth * progress, 0, this.barWidth * percentage, this.barHeight); + progress += percentage; + } + this.graphics.fillStyle(0x000000); + this.graphics.fillRect(this.barWidth * progress, 0, this.barWidth * (1 - progress), this.barHeight); + + this.graphics.lineStyle(2, 0xffffff); + this.graphics.strokeRoundedRect(-1, -1, this.barWidth+2, this.barHeight+2, 5); + this.graphics.lineStyle(1, 0x000000); + this.graphics.strokeRoundedRect(-3, -3, this.barWidth+6, this.barHeight+6, 5); + } + +} \ No newline at end of file diff --git a/src/minigame/roomba.ts b/src/minigame/roomba.ts new file mode 100644 index 0000000..3aca268 --- /dev/null +++ b/src/minigame/roomba.ts @@ -0,0 +1,126 @@ +import { HEIGHT, WIDTH } from "../main"; + +const SIZE = 85; +const ARROW_COLOR = 0x00ff00; +export interface RoombaConfig { + id: string; + x: number; + y: number; + angle: number; + color: number; + paintColor: number; + speed: number +} + + +export class Roomba extends Phaser.GameObjects.Container { + static SIZE = SIZE; + + private graphics: Phaser.GameObjects.Graphics; + private arrow: Phaser.GameObjects.Graphics; + + private lastPaintPosition: Phaser.Math.Vector2; + + private color: number; + private paintColor: number; + private speed: number; + + public readonly id: string; + + constructor(scene: Phaser.Scene, { id, x, y, angle, color, paintColor, speed} : RoombaConfig) { + super(scene, x, y); + + this.id = id; + this.color = color; + this.paintColor = paintColor; + this.speed = speed; + + this.graphics = new Phaser.GameObjects.Graphics(scene); + this.arrow = new Phaser.GameObjects.Graphics(scene); + this.lastPaintPosition = new Phaser.Math.Vector2(x, y); + + this.graphics.fillStyle(this.color); + this.graphics.fillCircle(0, 0, SIZE / 2); + this.graphics.fillCircle(SIZE/5 * 2, 0, SIZE / 4); + + scene.physics.world.enable(this); + const body = this.body! as Phaser.Physics.Arcade.Body; + body.setCollideWorldBounds(true) + body.setOffset(-SIZE / 2, -SIZE / 2); + body.setCircle(SIZE/2); + + this.add(this.arrow); + this.add(this.graphics); + + this.updateDirection(new Phaser.Math.Vector2(Math.cos(angle), Math.sin(angle))); + + this.initDragEvents(); + } + + private initDragEvents() { + this.graphics.setInteractive({ + hitArea: new Phaser.Geom.Circle(0, 0, SIZE / 2), + hitAreaCallback: Phaser.Geom.Circle.Contains, + useHandCursor: false, + draggable: true + }, Phaser.Geom.Circle.Contains); + + this.graphics.on('dragstart', () => { + this.arrow.visible = true; + }); + + this.graphics.on('drag', (event: any) => { + const dragOffset = new Phaser.Math.Vector2(event.x - this.x, event.y - this.y); + this.drawArrow(Phaser.Math.Vector2.ZERO, dragOffset); + }); + + this.graphics.on('dragend', (event: any) => { + const dragOffset = new Phaser.Math.Vector2(event.x - this.x, event.y - this.y); + this.scene.events.emit('roomba-dragged', { id: this.id, offset: dragOffset}); + this.drawArrow(new Phaser.Math.Vector2(0, 0), new Phaser.Math.Vector2(0, 0)); + this.arrow.visible = false; + }); + } + + private drawArrow(start: Phaser.Math.Vector2, end: Phaser.Math.Vector2) { + const arrow = this.arrow; + arrow.clear(); + + arrow.lineStyle(5, ARROW_COLOR); + arrow.fillStyle(ARROW_COLOR); + + this.arrow.lineBetween(start.x, start.y, end.x, end.y); + + const arrowAngle = Math.atan2(end.y - start.y, end.x - start.x); + const arrowAngleNormal = arrowAngle + Math.PI/2; + const arrowHeadLength = 10; + const arrowHeadWidth = 10; + + this.arrow.fillTriangle( + end.x + Math.cos(arrowAngleNormal) * arrowHeadWidth, + end.y + Math.sin(arrowAngleNormal) * arrowHeadWidth, + end.x + Math.cos(arrowAngle) * arrowHeadLength, + end.y + Math.sin(arrowAngle) * arrowHeadLength, + end.x - Math.cos(arrowAngleNormal) * arrowHeadWidth, + end.y - Math.sin(arrowAngleNormal) * arrowHeadWidth, + ); + } + + public updateDirection(direction: Phaser.Math.Vector2) { + this.graphics.rotation = direction.angle(); + + const normalized = direction.normalize(); + const body = this.body! as Phaser.Physics.Arcade.Body; + body.setVelocity(normalized.x * this.speed, normalized.y * this.speed); + } + + + public paintPath(graphic: Phaser.GameObjects.Graphics) { + graphic.lineStyle(SIZE, this.paintColor); + graphic.lineBetween(this.lastPaintPosition.x, this.lastPaintPosition.y, this.x, this.y); + graphic.fillStyle(this.paintColor) + graphic.fillCircle(this.x, this.y, SIZE/2); + this.lastPaintPosition = new Phaser.Math.Vector2(this.x, this.y); + } + +} \ No newline at end of file diff --git a/src/scene/base-scene-controller.ts b/src/scene/base-scene-controller.ts new file mode 100644 index 0000000..9246c07 --- /dev/null +++ b/src/scene/base-scene-controller.ts @@ -0,0 +1,103 @@ +import AzNopolyGame from "../game"; + + +export default class BaseSceneController { + + protected aznopoly: AzNopolyGame; + + private _scene: Phaser.Scene; + private packetType: string; + + private registeredMethods: string[] = []; + + /** + * A proxy object that allows for calling methods on the controller. + * Functions called on the proxy will be sent to all clients and executed asynchonously + * Functions must be registered with registerSyncedMethod + */ + protected syncProxy = new Proxy(this, { + get: (_, prop) => { + return this.onProxyCall(prop); + }, + }); + + constructor(scene: Phaser.Scene, aznopoly: AzNopolyGame) { + this._scene = scene; + this.aznopoly = aznopoly; + this.packetType = "CLIENT_MINIGAME_" + this.constructor.name.toUpperCase(); + } + + /** + * Should be called when the scene has initialized all of it's state + * and is ready to start receiving packets + */ + public onSceneReady() { + const listener = this.aznopoly.addPacketListener(this.packetType, this.onPacket.bind(this) as EventListener); + this._scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.aznopoly.removePacketListener(this.packetType, listener)); + } + + /** + * Registers a method to be allowed to be called with syncProxy + * @param method The method to be registered + */ + public registerSyncedMethod(method: Function) { + const name = method.name; + if (this.registeredMethods.includes(name)) { + throw new Error(`Packet executor with name ${name} already exists`); + } + this.registeredMethods.push(name); + } + + private isMethodAllowed(method: string) { + return this.registeredMethods.includes(method); + } + + private onProxyCall(prop: symbol | string) { + if (!this.isMethodAllowed(String(prop))) { + throw new Error(`This method was not registered for sync: ${String(prop)}`); + } + + return (...args: any[]) => { + const packet = this.sendExecPacket(String(prop), ...args); + this.executePacket(packet); + } + } + + private onPacket(event: CustomEvent<{type: string, data: any}>) { + const packet = event.detail; + this.executePacket(packet); + } + + private sendExecPacket(method: string, ...args: any[]) { + const packet = { + type: this.packetType, + data: { + method, + arguments: args + } + } + + this.aznopoly.broadcastPacket(packet); + return packet; + } + + private executePacket(packet: {type: string, data: {method: string, arguments: any[]}}) { + if (packet.type !== this.packetType) { + console.error(`Packet type ${packet.type} does not match expected type ${this.packetType}`); + return; + } + + if (!this.isMethodAllowed(packet.data.method)) { + console.error(`Method ${packet.data.method} is not allowed`); + return; + } + + const method = (this as any)[packet.data.method]; + if (typeof method !== "function") { + console.error(`Method ${packet.data.method} is not a function`); + return; + } + (method).apply(this, packet.data.arguments); + } + +} \ No newline at end of file diff --git a/src/scene/base-scene.ts b/src/scene/base-scene.ts index 6442784..b2645d2 100644 --- a/src/scene/base-scene.ts +++ b/src/scene/base-scene.ts @@ -4,7 +4,7 @@ import { SceneSwitcher } from "../scene-switcher"; import { PacketType, PlayerPacket, SceneChangePacket } from "../types/client"; -export class BaseScene extends Phaser.Scene { +export abstract class BaseScene extends Phaser.Scene { protected aznopoly: AzNopolyGame; private packetListener: [string, EventListener][] = []; @@ -18,11 +18,12 @@ export class BaseScene extends Phaser.Scene { init(data: any) { if(!this.aznopoly.client) return; + const launchMethod = (data || {}).launchMethod; if (this.sync) { if (this.aznopoly.isHost) { - SceneSwitcher.waitForPlayers(this.aznopoly, this.scene.key, data.launchMethod).then(() => { - this.onAllPlayerReady(); + SceneSwitcher.waitForPlayers(this.aznopoly, this.scene.key, launchMethod).then(() => { + this.hostOnAllPlayerReady(); }); } } @@ -36,9 +37,7 @@ export class BaseScene extends Phaser.Scene { SceneSwitcher.updateScene(this.aznopoly, this.scene.key); } - protected onAllPlayerReady() { - - } + protected abstract hostOnAllPlayerReady(): void; protected addPacketListener(type: string, callback: (packet: T) => void) { const listener: EventListener = (event: Event) => { @@ -56,10 +55,13 @@ export class BaseScene extends Phaser.Scene { } private onChangeScene(packet: SceneChangePacket) { - if (!this.aznopoly.isPlayerHost(packet.sender)) return; + if (!this.aznopoly.isPlayerHost(packet.sender)) { + console.warn("Received scene change packet from non-host player"); + return; + } - console.log("launching scene", packet.data.scene, "from", this.scene.key) if (packet.data.launchMethod == "launch") { + this.scene.sleep(); this.scene.launch(packet.data.scene, { previousScene: this.scene.key}); } else { this.scene.start(packet.data.scene, { previousScene: this.scene.key}); diff --git a/src/scene/game-scene.ts b/src/scene/game-scene.ts index 8e677e4..8184c4d 100644 --- a/src/scene/game-scene.ts +++ b/src/scene/game-scene.ts @@ -14,12 +14,14 @@ export default class GameScene extends BaseScene { private currentPlayerIndex: number = 0; private currentTurnValue!: Phaser.GameObjects.Text; + + private turnNumber: number = 0; preload() { GameBoard.preload(this); } - onAllPlayerReady() { + hostOnAllPlayerReady() { this.aznopoly.room.connectedPlayerIds.forEach(uuid => { this.board.addPlayer(uuid); }); @@ -28,11 +30,12 @@ export default class GameScene extends BaseScene { } create() { + console.log("GameScene created") const boardSize = HEIGHT * 0.8; this.board = this.add.existing(new GameBoard(this.aznopoly, this, (WIDTH - boardSize) * 0.5 - 200, (HEIGHT - boardSize) * 0.5, boardSize)); const playerList = this.add.existing(new PlayerList(this, false, WIDTH - 300, 0, 250)); - playerList.updatePlayerList(this.aznopoly.room.connectedPlayerIds.map(e => ({name: this.aznopoly.room.getPlayerName(e), host: false}))) + playerList.updatePlayerList(this.aznopoly.room.connectedPlayerIds.map(e => ({uuid: e, name: this.aznopoly.room.getPlayerName(e), host: false}))) playerList.updateTitle(""); this.rollButton = this.add.existing(new AzNopolyButton(this, "Roll Dice", WIDTH - 150, HEIGHT - 100, this.onRollClick.bind(this))); @@ -98,7 +101,18 @@ export default class GameScene extends BaseScene { this.startTurn(); } + private startMiniGame(name: string) { + this.scene.sleep(); + this.scene.launch(name, { launchMethod: "launch", previousScene: this.scene.key }); + } + private startTurn() { + this.turnNumber++; + if (this.turnNumber > 1 && this.currentPlayerIndex == 0) { + this.startMiniGame("minigame-roomba"); + } + + const currentPlayer = this.aznopoly.room.connectedPlayerIds[this.currentPlayerIndex]; const packet: GameTurnStartPacket = { diff --git a/src/scene/lobby-scene.ts b/src/scene/lobby-scene.ts index 9bd2cd0..0e178f2 100644 --- a/src/scene/lobby-scene.ts +++ b/src/scene/lobby-scene.ts @@ -62,7 +62,7 @@ export default class LobbyScene extends BaseScene { private updatePlayerList() { const arr = this.aznopoly.room.connectedPlayerIds; - const connectedNames = arr.map(uuid => ({ name: this.aznopoly.room.getPlayerName(uuid), host: this.aznopoly.isPlayerHost(uuid) })) + const connectedNames = arr.map(uuid => ({ uuid, name: this.aznopoly.room.getPlayerName(uuid), host: this.aznopoly.isPlayerHost(uuid) })) this.playerList.updatePlayerList(connectedNames); } diff --git a/src/scene/minigame-scene.ts b/src/scene/minigame-scene.ts index 0d25462..7c623cf 100644 --- a/src/scene/minigame-scene.ts +++ b/src/scene/minigame-scene.ts @@ -33,9 +33,8 @@ export default abstract class MinigameScene extends BaseScene { super.init(data); this.addPacketListener(PacketType.MINIGAME_READY, this.onMiniGameReady.bind(this)); this.addPacketListener(PacketType.MINIGAME_RESULT, this.onResultPacket.bind(this)); - - console.log("Iniated", data) - this.previousScene = data.previousScene; + + this.previousScene = data?.previousScene; } create() { @@ -46,11 +45,8 @@ export default abstract class MinigameScene extends BaseScene { super.create(); } - protected onAllPlayerReady(): void { - setTimeout(() => { - this.overlay.setVisible(false); - this.startMiniGame(); - }, 1500); + protected hostOnAllPlayerReady(): void { + this.startMiniGame(); } private startMiniGame() { @@ -62,14 +58,17 @@ export default abstract class MinigameScene extends BaseScene { this.aznopoly.client.sendPacket(packet); setTimeout(() => { - this.onMinigameStart(); - }, 50) // simulate network delay + this.onMiniGameReady(packet); + }, 10) // simulate network delay } - abstract onMinigameStart(): void; + abstract onMiniGameStart() : void; private onMiniGameReady(_: MinigameReadyPacket) { - this.overlay.setVisible(false); + setTimeout(() => { + this.overlay.setVisible(false); + this.onMiniGameStart(); + }, 1000); } private onResultPacket(packet: MinigameResultPacket) { @@ -96,8 +95,7 @@ export default abstract class MinigameScene extends BaseScene { private onGameOver() { this.scene.stop(); - this.scene.resume(this.previousScene); - console.log("Resuming", this.previousScene) + this.scene.wake(this.previousScene); } protected endGame(playerWon: string[], sorted: boolean) { diff --git a/src/scene/minigame/roomba-scene-controller.ts b/src/scene/minigame/roomba-scene-controller.ts new file mode 100644 index 0000000..e7163ee --- /dev/null +++ b/src/scene/minigame/roomba-scene-controller.ts @@ -0,0 +1,71 @@ +import AzNopolyGame from "../../game"; +import { HEIGHT as GAME_HEIGHT, WIDTH as GAME_WIDTH } from "../../main"; +import { Roomba, RoombaConfig } from "../../minigame/roomba"; +import { getColorFromUUID } from "../../util"; +import BaseSceneController from "../base-scene-controller"; +import { RoombaScene } from "./roomba-scene"; +import convert from 'color-convert'; + + +export default class RoombaSceneController extends BaseSceneController { + + private scene: RoombaScene; + + constructor(scene: RoombaScene, aznopoly: AzNopolyGame) { + super(scene, aznopoly); + + this.scene = scene; + this.aznopoly = aznopoly; + + this.registerSyncedMethod(this.initRoombas); + this.registerSyncedMethod(this.updateRoombaDirection); + } + + public hostInit() { + const roombaConfigs = []; + + for (let j = 0; j < this.aznopoly.players.length; j++) { + for (let i = 0; i < 5; i++) { + roombaConfigs.push(this.generateRandomRoombaConfig(this.aznopoly.players[j])); + } + } + + this.syncProxy.initRoombas(roombaConfigs); + } + + public onSceneReady() { + super.onSceneReady(); + + this.scene.events.on("roomba-dragged", ({id, offset} : {id: string, offset: Phaser.Math.Vector2}) => { + this.syncProxy.updateRoombaDirection(id, offset); + }); + } + + private updateRoombaDirection(id: string, direction: Phaser.Math.Vector2) { + this.scene.updateRoombaDirection(id, new Phaser.Math.Vector2(direction.x, direction.y)); + } + + private initRoombas(configs: RoombaConfig[]) { + this.scene.initRoombas(configs); + } + + private generateRandomRoombaConfig(playerid: string) { + const x = Math.random() * (GAME_WIDTH - Roomba.SIZE * 2) + Roomba.SIZE; + const y = Math.random() * (GAME_HEIGHT - Roomba.SIZE * 2) + Roomba.SIZE; + + const color = getColorFromUUID(playerid); + const paintColor = convert.hex.hsl("0x" + color.toString(16)); + paintColor[2] = 40; + const paintColorHex = Number("0x" + convert.hsl.hex(paintColor)); + return { + id: Math.random().toString(36).substring(7), + x, + y, + angle: Math.random() * Math.PI * 2, + color: color, + paintColor: paintColorHex, + speed: 50 + } + } + +} \ No newline at end of file diff --git a/src/scene/minigame/roomba-scene.ts b/src/scene/minigame/roomba-scene.ts new file mode 100644 index 0000000..a12d676 --- /dev/null +++ b/src/scene/minigame/roomba-scene.ts @@ -0,0 +1,160 @@ +import MinigameScene, { MinigameReadyPacket } from "../minigame-scene"; +import { Roomba, RoombaConfig } from "../../minigame/roomba"; +import { HEIGHT, WIDTH } from "../../main"; +import RoombaSceneController from "./roomba-scene-controller"; +import AzNopolyGame from "../../game"; +import ColorProgressBar from "../../minigame/color-progressbar"; +import convert from 'color-convert'; + +const GRAPHICS_SWAP_TIME = 1; +const PAINT_REFRESH_TIME = 0.5; +export class RoombaScene extends MinigameScene { + + private controller: RoombaSceneController; + + private roombas: Roomba[] = []; + + private timeSinceLastPaint = 0; + private timeSinceGraphicsSwap = 0; + + private paint!: Phaser.GameObjects.Graphics; + private paintTexture!: Phaser.Textures.DynamicTexture; + + private colorProgressBar!: ColorProgressBar; + + constructor(aznopoly: AzNopolyGame) { + super(aznopoly, true); + + this.controller = new RoombaSceneController(this, aznopoly); + } + + preload() { + super.preload(); + } + + create() { + super.create(); + this.physics.world.setBounds(0, 0, WIDTH, HEIGHT); + + this.paint = this.add.graphics(); + this.paintTexture = this.textures.addDynamicTexture("roomba-paint", WIDTH, HEIGHT)!; + + this.colorProgressBar = new ColorProgressBar(this, WIDTH / 2 - 200, 25, 400, 40); + + this.add.sprite(0, 0, "roomba-paint").setOrigin(0, 0).setDepth(-1); + this.add.existing(this.colorProgressBar); + } + + update(_: number, delta: number) { + this.paintRoombaUpdate(delta); + this.graphicSwapUpdate(delta); + } + + onMiniGameStart() { + this.controller.onSceneReady(); + if (this.aznopoly.isHost) { + this.controller.hostInit(); + } + } + + public initRoombas(configs: RoombaConfig[]) { + this.roombas = configs.map(config => new Roomba(this, config)); + this.roombas.forEach(roomba => { + this.add.existing(roomba) + roomba.paintPath(this.paint); + }); + this.physics.add.collider(this.roombas, this.roombas); + } + + public updateRoombaDirection(roombaId: String, direction: Phaser.Math.Vector2) { + const roomba = this.roombas.find(roomba => roomba.id === roombaId); + if (roomba) { + roomba.updateDirection(direction); + } else { + console.error("Roomba not found"); + } + } + + private paintRoombaUpdate(delta: number) { + this.timeSinceLastPaint += delta / 1000; + if (this.timeSinceLastPaint > PAINT_REFRESH_TIME) { + this.timeSinceLastPaint = 0; + this.roombas.forEach(roomba => { + roomba.paintPath(this.paint); + }); + } + } + + private graphicSwapUpdate(delta: number) { + this.timeSinceGraphicsSwap += delta / 1000; + if (this.timeSinceGraphicsSwap > GRAPHICS_SWAP_TIME) { + this.timeSinceGraphicsSwap = 0; + + this.paintTexture.draw(this.paint, 0, 0); + this.calculatePaintPercentage(); + + this.paint.clear(); + } + } + + private calculatePaintPercentage() { + if (this.paintTexture.renderTarget) { + const renderer = this.paintTexture.renderer as Phaser.Renderer.WebGL.WebGLRenderer; + + var total = WIDTH * HEIGHT * 4; + var pixels = new Uint8ClampedArray(total); + + const prevFramebuffer = renderer.currentFramebuffer; + renderer.setFramebuffer(this.paintTexture.renderTarget.framebuffer) + renderer.gl.readPixels(0, 0, WIDTH, HEIGHT, renderer.gl.RGBA, renderer.gl.UNSIGNED_BYTE, pixels); + renderer.setFramebuffer(prevFramebuffer); + + const result = this.readPixelArray(pixels); + this.updateProgressBar(result); + } else { + var copyCanvas = Phaser.Display.Canvas.CanvasPool.createWebGL(this, WIDTH, HEIGHT) + + var ctx = copyCanvas.getContext('2d')!; + ctx.drawImage(this.paintTexture.canvas, 0, 0, WIDTH, HEIGHT, 0, 0, WIDTH, HEIGHT); + + const pixels = ctx.getImageData(0, 0, WIDTH, HEIGHT).data; + const result = this.readPixelArray(pixels); + this.updateProgressBar(result); + + Phaser.Display.Canvas.CanvasPool.remove(copyCanvas); + } + } + + private updateProgressBar(colors: {[key: string]: number}) { + const colorMap = new Map(); + for (const [color, percentage] of Object.entries(colors)) { + colorMap.set(parseInt(color, 16), percentage); + } + this.colorProgressBar.setColors(colorMap); + } + + private readPixelArray(pixels: Uint8ClampedArray) { + const colors: {[key: string]: number} = {}; + for (let x = 0; x < WIDTH; x++) { + for (let y = 0; y < HEIGHT; y++) { + const index = (x + y * WIDTH) * 4; + const r = pixels[index]; + const g = pixels[index + 1]; + const b = pixels[index + 2]; + + const rgbHex = convert.rgb.hex([r, g, b]); + colors[rgbHex] = colors[rgbHex] ? colors[rgbHex] + 1 : 1; + } + } + return Object.keys(colors) + .sort((a, b) => colors[b] - colors[a]) + .slice(0, 5) + .reduce((prev, cur) => { + prev[cur] = (colors[cur] / (WIDTH * HEIGHT)); + return prev; + }, {} as {[key: string]: number}); + + } + + +} \ No newline at end of file diff --git a/src/types/client.ts b/src/types/client.ts index f92e140..1dbcf91 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -25,9 +25,12 @@ export enum PacketType { MINIGAME_RESULT = "CLIENT_MINIGAME_RESULT", MINIGAME_READY = "CLIENT_MINIGAME_READY", + MINIGAME_START = "CLIENT_MINIGAME_START", MINIGAME_SIMON_SAYS = "CLIENT_MINIGAME_SIMON_SAYS", MINIGAME_SIMON_SAYS_ACTION = "CLIENT_MINIGAME_SIMON_SAYS_ACTION", + + MINIGAME_ROOMBA = "CLIENT_MINIGAME_ROOMBA", // Unused EXAMPLE = "CLIENT_EXAMPLE", diff --git a/src/ui/player-list.ts b/src/ui/player-list.ts index b53b104..4f89f89 100644 --- a/src/ui/player-list.ts +++ b/src/ui/player-list.ts @@ -47,14 +47,14 @@ export default class PlayerList extends Phaser.GameObjects.Container { this.title.setText(title); } - private createPlayerEntry(name: string, host: boolean): Entry { + private createPlayerEntry(name: string, host: boolean, uuid: string): Entry { const headKey = host ? "host-crown" : "player-icon"; const head = new Phaser.GameObjects.Image(this.scene, 0, 0, headKey); this.add(head); const headScale = LINE_HEIGHT / head.height; head.setScale(headScale, headScale); head.setOrigin(0, 0.5); - head.tint = getColorFromUUID(name); + head.tint = getColorFromUUID(uuid); const text = new Phaser.GameObjects.Text(this.scene, 50, 0, name, FONT_STYLE_BODY); this.add(text); @@ -69,7 +69,7 @@ export default class PlayerList extends Phaser.GameObjects.Container { return { head, text, tail }; } - public updatePlayerList(players: {name: string, host: boolean}[]) { + public updatePlayerList(players: {uuid: string, name: string, host: boolean}[]) { const newEntries: Entry[] = []; const oldEntries = this.playerEntries;; @@ -82,7 +82,7 @@ export default class PlayerList extends Phaser.GameObjects.Container { }); players.forEach(player => { - const entry = this.createPlayerEntry(player.name, player.host); + const entry = this.createPlayerEntry(player.name, player.host, player.uuid); newEntries.push(entry); }); diff --git a/src/util.ts b/src/util.ts index 77f6fc4..347ae50 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ import { Scene } from "phaser"; import { FONT_STYLE_BUTTON, FONT_STYLE_BUTTON_HOVER } from "./style"; +import convert from 'color-convert'; export function easeOutElastic(x: number): number { const c4 = (2 * Math.PI) / 3; @@ -34,5 +35,6 @@ export function getColorFromUUID(uuid: string) { hash = uuid.charCodeAt(i) + ((hash << 5) - hash); } const color = Math.floor(Math.abs((Math.sin(hash) * 16777215) % 1) * 16777215); - return color; + + return Number("0x" + convert.hsl.hex([color % 360, 100, 50])); } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c8efd2f..76a4578 100644 --- a/yarn.lock +++ b/yarn.lock @@ -876,6 +876,18 @@ dependencies: "@babel/types" "^7.20.7" +"@types/color-convert@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.3.tgz#e93f5c991eda87a945058b47044f5f0008b0dce9" + integrity sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg== + dependencies: + "@types/color-name" "*" + +"@types/color-name@*": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.3.tgz#c488ac2e519c9795faa0d54e8156d54e66adc4e6" + integrity sha512-87W6MJCKZYDhLAx/J1ikW8niMvmGRyY+rpUxWpL1cO7F8Uu5CHuQoFv+R0/L5pgNdW4jTyda42kv60uwVIPjLw== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz" @@ -1184,7 +1196,7 @@ color-convert@^1.9.0: color-convert@^2.0.1: version "2.0.1" - resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== dependencies: color-name "~1.1.4"