From 50500124a8f94e10e6d6ad31d585869216e17fd1 Mon Sep 17 00:00:00 2001 From: Nick Kosarev Date: Fri, 5 Jul 2024 12:36:46 +0000 Subject: [PATCH] feat: plugin for stream --- .../stream-plugin/customWebSocketService.ts | 85 ++++ .../game/stream-plugin/unitsOnStreamPlugin.ts | 369 ++++++++++++++++++ src/lib/game/types.ts | 20 + .../[lang]/(game)/stream/+layout.svelte | 17 + src/routes/[lang]/(game)/stream/+page.svelte | 45 +++ 5 files changed, 536 insertions(+) create mode 100644 src/lib/game/stream-plugin/customWebSocketService.ts create mode 100644 src/lib/game/stream-plugin/unitsOnStreamPlugin.ts create mode 100644 src/routes/[lang]/(game)/stream/+layout.svelte create mode 100644 src/routes/[lang]/(game)/stream/+page.svelte diff --git a/src/lib/game/stream-plugin/customWebSocketService.ts b/src/lib/game/stream-plugin/customWebSocketService.ts new file mode 100644 index 00000000..e3dc0754 --- /dev/null +++ b/src/lib/game/stream-plugin/customWebSocketService.ts @@ -0,0 +1,85 @@ +import type { WebSocketMessage } from '@hmbanan666/chat-game-api' +import type { UnitsOnStream } from '$lib/game/types' +import { browser } from '$app/environment' +import { config } from '$lib/config' + +export class CustomWebSocketService { + socket!: WebSocket + messagesPerSecond = 0 + kbitPerSecond = 0 + game: UnitsOnStream + + constructor(game: UnitsOnStream) { + this.game = game + + if (browser && this.game.options.isSocketOn) { + this.#init() + } + } + + update() {} + + async #handleMessage(message: WebSocketMessage) { + if (message.type === 'MESSAGE') { + const { text, player } = message.data + this.game.handleMessage({ text, playerId: player.id }) + } + // if (message.type === 'RAID_STARTED') { + // this.game.audio.playSound('MARCHING_WITH_HORNS') + // } + // if (message.type === 'GROUP_FORM_STARTED') { + // this.game.audio.playSound('MARCHING_WITH_HORNS') + // } + // if (message.type === 'MAIN_QUEST_STARTED') { + // this.game.audio.playSound('MARCHING_WITH_HORNS') + // } + // if (message.type === 'SCENE_CHANGED') { + // this.game.rebuildScene() + // } + // if (message.type === 'IDEA_CREATED') { + // this.game.audio.playSound('YEAH') + // } + } + + #init() { + this.socket = new WebSocket(config.websocketUrl ?? '', [this.game.id]) + + this.#setMessagesPerSecondHandler() + + this.socket.addEventListener('open', () => { + this.socket.send(JSON.stringify({ type: 'GAME_HANDSHAKE', id: this.game.id, profileJWT: this.game.profileJWT })) + }) + + this.socket.addEventListener('message', (event) => { + const message = this.#parse(event.data.toString()) + if (!message) { + return + } + + this.messagesPerSecond += 1 + const bytes = JSON.stringify(message).length + this.kbitPerSecond += Math.round((bytes * 8) / 1024) + + void this.#handleMessage(message) + }) + } + + #parse(message: string): WebSocketMessage | undefined { + const parsed = JSON.parse(message) + if (parsed) { + return parsed as WebSocketMessage + } + + return undefined + } + + #setMessagesPerSecondHandler() { + return setInterval(() => { + // console.log( + // `${this.messagesPerSecond} msg/s, ${this.kbitPerSecond} kbit/s`, + // ) + this.messagesPerSecond = 0 + this.kbitPerSecond = 0 + }, 1000) + } +} diff --git a/src/lib/game/stream-plugin/unitsOnStreamPlugin.ts b/src/lib/game/stream-plugin/unitsOnStreamPlugin.ts new file mode 100644 index 00000000..fe22e400 --- /dev/null +++ b/src/lib/game/stream-plugin/unitsOnStreamPlugin.ts @@ -0,0 +1,369 @@ +import { Application, Container } from 'pixi.js' +import { createId } from '@paralleldrive/cuid2' +import { gameOptions } from '../store.svelte' +import { ServerService } from '../services/server/serverService' +import { BaseWagon } from '../objects/baseWagon' +import { FlagObject } from '../objects/flagObject' +import { ANSWER } from '../services/action/answer' +import { Player } from '../objects/units/player' +import { MoveOffScreenAndSelfDestroyScript } from '../scripts/moveOffScreenAndSelfDestroyScript' +import { CustomWebSocketService } from './customWebSocketService' +import type { + Game, + GameObject, + GameObjectPlayer, + GameOptions, + IGameActionResponse, + UnitsOnStream, +} from '$lib/game/types' +import { AssetsManager } from '$lib/game/utils/assetsManager' +import { getRandomInRange } from '$lib/utils/random' +import { MoveToTargetScript } from '$lib/game/scripts/moveToTargetScript' +import { EventService } from '$lib/game/services/event/eventService' +import { Group } from '$lib/game/common/group' +import { PlayerService } from '$lib/game/services/player/playerService' +import type { Wagon } from '$lib/game/services/wagon/interface' +import { getDateMinusMinutes } from '$lib/utils/date' + +interface UnitsOnStreamPluginOptions { + isSocketOn?: boolean +} + +export class UnitsOnStreamPlugin extends Container implements UnitsOnStream { + id: string + profileJWT?: string + options: GameOptions + children: Game['children'] = [] + app: Application + scene!: Game['scene'] + tick: Game['tick'] = 0 + group: Group + wagon!: BaseWagon + + eventService: EventService + playerService: PlayerService + websocketService: CustomWebSocketService + serverService: ServerService + + #outFlags: FlagObject[] = [] + #nearFlags: FlagObject[] = [] + + #cameraX = 0 + #cameraY = 0 + #cameraPerfectX = 0 + #cameraPerfectY = 0 + + constructor({ isSocketOn }: UnitsOnStreamPluginOptions) { + super() + + this.options = gameOptions + this.options.isSocketOn = isSocketOn ?? false + + this.id = createId() + this.app = new Application() + this.group = new Group() + + this.eventService = new EventService(this as any) + this.playerService = new PlayerService(this as any) + this.websocketService = new CustomWebSocketService(this) + this.serverService = new ServerService(this as any) + } + + async init() { + await this.app.init({ + backgroundAlpha: 0, + antialias: false, + roundPixels: false, + resolution: 1, + resizeTo: window, + }) + + await AssetsManager.init() + + // this.audio.playSound("FOREST_BACKGROUND") + + this.#initWagon({ x: 1, y: 1 }) + + this.app.stage.addChild(this) + + // this.initScene('MOVING') + + this.app.ticker.add(() => { + if (this.options.isPaused) { + return + } + + this.tick = this.app.ticker.FPS + + this.eventService.update() + // this.wagonService.update() + this.#removeInactivePlayers() + this.#updateObjects() + this.#removeDestroyedObjects() + + this.#changeCameraPosition(this.wagon) + this.#moveCamera() + }) + } + + async play() { + this.options.isPaused = false + + // setInterval(() => { + // console.log("FPS", this.app.ticker.FPS) + // console.log("Objects", this.children.length) + // }, 1000) + } + + destroy() { + this.app.destroy() + + super.destroy() + } + + removeObject(obj: GameObject) { + this.removeChild(obj) + } + + get activePlayers() { + return this.children.filter((obj) => obj.type === 'PLAYER') as GameObjectPlayer[] + } + + checkIfThisFlagIsTarget(id: string): boolean { + for (const obj of this.children) { + if (obj.target?.id === id) { + return true + } + } + return false + } + + findObject(id: string): GameObject | undefined { + return this.children.find((obj) => obj.id === id) + } + + rebuildScene(): void { + this.removeChild(...this.children) + } + + get randomOutFlag() { + return this.#outFlags[Math.floor(Math.random() * this.#outFlags.length)] + } + + get randomNearFlag() { + return this.#nearFlags[Math.floor(Math.random() * this.#nearFlags.length)] + } + + #initWagon({ x, y }: { x: number, y: number }) { + this.wagon = new BaseWagon({ game: this as any, x, y }) + this.wagon.init() + + this.#initOutFlags() + this.#initNearFlags() + } + + #initOutFlags(count = 1) { + for (let i = 0; i < count; i++) { + this.#outFlags.push(this.#generateRandomOutFlag()) + } + } + + #initNearFlags(count = 20) { + for (let i = 0; i < count; i++) { + this.#nearFlags.push(this.#generateRandomNearFlag()) + } + } + + #generateRandomOutFlag() { + const offsetX = -500 + const offsetY = 30 + + const flag = new FlagObject({ + game: this as any, + variant: 'OUT_OF_SCREEN', + x: this.wagon.x + offsetX, + y: this.wagon.y + offsetY, + offsetX, + offsetY, + }) + + return flag + } + + #generateRandomNearFlag() { + const minOffsetX = 200 + + const offsetX + = getRandomInRange(minOffsetX, this.app.screen.width - 500) + const offsetY = 30 + + const flag = new FlagObject({ + game: this as any, + variant: 'WAGON_NEAR_MOVEMENT', + x: this.wagon.x + offsetX, + y: this.wagon.y + offsetY, + offsetX, + offsetY, + }) + + return flag + } + + async handleMessage({ playerId, text }: { playerId: string, text: string }): Promise { + const player = await this.#initPlayer(playerId) + if (!player) { + return ANSWER.NO_PLAYER_ERROR + } + + return this.#handleMessage(player, text) + } + + async #initPlayer(id: string) { + const player = await this.findOrCreatePlayer(id) + if (!player) { + return + } + + this.group.join(player) + this.addChild(player) + player.updateLastActionAt() + + return player + } + + async findOrCreatePlayer(id: string): Promise { + const player = this.#findPlayer(id) + if (!player) { + return this.#createPlayer(id) + } + + return player + } + + #findPlayer(id: string) { + return this.children.find((p) => p.id === id && p.type === 'PLAYER') as Player | undefined + } + + async #createPlayer(id: string) { + const player = new Player({ game: this as any, id, x: -100, y: -100 }) + await player.init() + + const flag = this.randomOutFlag + player.x = flag.x + player.y = flag.y + + return player + } + + #removeInactivePlayers() { + for (const player of this.activePlayers) { + const checkTime = getDateMinusMinutes(8) + if (player.lastActionAt.getTime() <= checkTime.getTime()) { + if (player.script) { + continue + } + + const target = this.randomOutFlag + const selfDestroyFunc = () => { + this.group.remove(player) + } + + player.script = new MoveOffScreenAndSelfDestroyScript({ + target, + object: player, + selfDestroyFunc, + }) + } + } + } + + async #handleMessage(player: GameObjectPlayer, text: string): Promise { + player.addMessage(text) + + return ANSWER.OK + } + + #updateObjects() { + for (const object of this.children) { + object.animate() + object.live() + + if (object.type === 'PLAYER') { + this.#updatePlayer(object as GameObjectPlayer) + } + } + } + + #updatePlayer(object: GameObjectPlayer) { + if (object.script) { + return + } + + if (object.state === 'IDLE') { + const random = getRandomInRange(1, 150) + if (random <= 1) { + const target = this.randomNearFlag + + object.script = new MoveToTargetScript({ + object, + target, + }) + } + } + } + + #removeDestroyedObjects() { + for (const object of this.children) { + if (object.state === 'DESTROYED') { + const index = this.children.indexOf(object) + this.children.splice(index, 1) + return + } + } + } + + #changeCameraPosition(wagon: Wagon) { + const columnWidth = this.app.screen.width / 8 + const rowHeight = this.app.screen.height / 8 + + const leftPadding = columnWidth - 20 + const topPadding = rowHeight * 7 - 5 + + this.#cameraPerfectX = -wagon.x + leftPadding + this.#cameraPerfectY = -wagon.y + topPadding + + // If first load + if (Math.abs(-wagon.x - this.#cameraX) > 3000) { + this.#cameraX = this.#cameraPerfectX + } + if (Math.abs(-wagon.y - this.#cameraY) > 3000) { + this.#cameraY = this.#cameraPerfectY + } + } + + #moveCamera() { + const cameraSpeedPerSecond = 25 + const cameraDistance = cameraSpeedPerSecond / this.tick + + const bufferX = Math.abs(this.#cameraPerfectX - this.#cameraX) + const moduleX = this.#cameraPerfectX - this.#cameraX > 0 ? 1 : -1 + const addToX = bufferX > cameraDistance ? cameraDistance : bufferX + + if (this.#cameraX !== this.#cameraPerfectX) { + this.#cameraX += addToX * moduleX + } + + const bufferY = Math.abs(this.#cameraPerfectY - this.#cameraY) + const moduleY = this.#cameraPerfectY - this.#cameraY > 0 ? 1 : -1 + const addToY = bufferY > cameraDistance ? cameraDistance : bufferY + + if (this.#cameraY !== this.#cameraPerfectY) { + this.#cameraY += addToY * moduleY + } + + if (this.parent) { + this.parent.x = this.#cameraX + this.parent.y = this.#cameraY + } + } +} diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index a4940cfa..f590ec22 100644 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -22,6 +22,26 @@ import type { import type { GameActionService } from '$lib/game/services/action/interface' import type { GameQuestService } from '$lib/game/services/quest/interface' +export interface UnitsOnStream extends Container { + id: string + profileJWT?: string + options: GameOptions + children: GameObject[] + tick: number + scene: GameScene + group: IGameGroup + activePlayers: GameObjectPlayer[] + eventService: GameEventService + playerService: GamePlayerService + serverService: GameServerService + play: () => void + checkIfThisFlagIsTarget: (id: string) => boolean + findObject: (id: string) => GameObject | undefined + removeObject: (obj: GameObject) => void + rebuildScene: () => void + handleMessage: ({ playerId, text }: { playerId: string, text: string }) => Promise +} + export interface Game extends Container { id: string profileJWT?: string diff --git a/src/routes/[lang]/(game)/stream/+layout.svelte b/src/routes/[lang]/(game)/stream/+layout.svelte new file mode 100644 index 00000000..e59ac339 --- /dev/null +++ b/src/routes/[lang]/(game)/stream/+layout.svelte @@ -0,0 +1,17 @@ + + +
+ +
+ + diff --git a/src/routes/[lang]/(game)/stream/+page.svelte b/src/routes/[lang]/(game)/stream/+page.svelte new file mode 100644 index 00000000..8bbeb1de --- /dev/null +++ b/src/routes/[lang]/(game)/stream/+page.svelte @@ -0,0 +1,45 @@ + + +
+
+
+ +