├── src ├── left │ ├── LeftArmy.ts │ └── index.html ├── right │ ├── RightArmy.ts │ └── index.html ├── typings.d.ts ├── common │ ├── battle │ │ ├── BattleSide.ts │ │ ├── BattleUnitBattleState.ts │ │ ├── BattleState.model.ts │ │ ├── views │ │ │ ├── ConnectionClosedView.ts │ │ │ ├── WinView.ts │ │ │ ├── LostView.ts │ │ │ ├── AttentionView.ts │ │ │ ├── WaitingView.ts │ │ │ └── ResultsView.ts │ │ ├── BattleGame.ts │ │ ├── UnitsStack.ts │ │ ├── BulletDrawer.ts │ │ ├── BattleStatistics.ts │ │ ├── BattleGameScreen.tsx │ │ ├── BattleFieldDrawer.ts │ │ └── BattleSession.ts │ ├── helpers │ │ ├── color.ts │ │ ├── font.ts │ │ ├── elementHasParent.ts │ │ ├── getDistanceFactor.ts │ │ ├── random.ts │ │ ├── GraphNode.ts │ │ ├── mergeDeep.ts │ │ ├── AsyncSequence.ts │ │ ├── Maybe.ts │ │ ├── HexagonalGraph.ts │ │ ├── Grid.ts │ │ ├── BinaryHeap.ts │ │ ├── Astar.ts │ │ └── Graph.ts │ ├── client │ │ ├── EMPTY_ARMY.ts │ │ ├── EnemyState.ts │ │ ├── ClientState.ts │ │ ├── ClientDisplay.tsx │ │ ├── ClientComponent.ts │ │ └── ClientApp.tsx │ ├── RoomService.ts │ ├── Environment.ts │ ├── WebComponent.ts │ ├── state.model.ts │ ├── documentation │ │ ├── AccordionSection.tsx │ │ ├── BasicJS.tsx │ │ ├── BasicFAQ.tsx │ │ ├── HowCodeWorks.tsx │ │ ├── UsefulTips.tsx │ │ ├── FirstSteps.tsx │ │ ├── Documentation.tsx │ │ └── UnitApi.tsx │ ├── characters │ │ ├── AnimationsCreator.ts │ │ └── CharactersList.ts │ ├── InjectDectorator.ts │ ├── console │ │ ├── BattleConsole.tsx │ │ └── ConsoleService.ts │ ├── roomTimer │ │ └── RoomTimer.tsx │ ├── codeSandbox │ │ ├── getUnitApi.ts │ │ └── CodeSandbox.ts │ ├── WebsocketConnection.ts │ └── ApiService.ts ├── App.ts ├── admin │ ├── IndexApp.tsx │ ├── Wreath.tsx │ ├── PlayerLink.tsx │ ├── index.html │ ├── PromptModal.tsx │ ├── PromptService.tsx │ ├── RoomListComponent.tsx │ ├── AdminApp.tsx │ └── LeadersGridComponent.tsx ├── master │ ├── index.html │ ├── CodeDisplay.tsx │ ├── MasterApp.tsx │ └── MasterScreen.tsx ├── index.html └── player │ ├── editor │ ├── SandboxAutocomplete.ts │ ├── AceEditor.tsx │ └── EditorComponent.tsx │ ├── toolbar │ ├── Toolbar.tsx │ └── UnitItem.tsx │ ├── gameDebug │ └── GameDebug.tsx │ └── PlayerScreen.tsx ├── img ├── $_unit.png ├── arrow.png ├── fire.png ├── snow.png ├── stone.png ├── wreath.png ├── css_unit.png ├── game_pre.gif ├── pepe-sad.png ├── pwa_unit.png ├── run_code.mov ├── select_unit.mov ├── character_ork.png ├── tuttorial_id.png ├── tuttorial_run.png ├── character_magic.png ├── character_nekr.png ├── character_null.png ├── character_varvar.png ├── character_winter.png ├── scripting_guide.mov ├── tuttorial_select.png ├── tuttorial_send.png ├── character_palladin.png └── push.svg ├── .gitignore ├── server ├── models │ ├── IApiFullResponse.model.ts │ └── RoomModel.ts ├── clients │ ├── Guest.ts │ ├── LeftPlayer.ts │ ├── RightPlayer.ts │ ├── Master.ts │ ├── Admin.ts │ └── Client.ts ├── storages │ ├── LeaderBoard.ts │ ├── AbstractFileBasedStorage.ts │ ├── RoomStorage.ts │ └── ConnectionsStorage.ts ├── index.ts ├── AuthController.ts ├── SocketMiddleware.ts ├── ApiController.ts └── Room.ts ├── tsconfig.json ├── README.md ├── package.json └── webpack.config.js /src/left/LeftArmy.ts: -------------------------------------------------------------------------------- 1 | 2 | export class LeftArmy {} -------------------------------------------------------------------------------- /src/right/RightArmy.ts: -------------------------------------------------------------------------------- 1 | 2 | export class RightArmy {} -------------------------------------------------------------------------------- /img/$_unit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/$_unit.png -------------------------------------------------------------------------------- /img/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/arrow.png -------------------------------------------------------------------------------- /img/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/fire.png -------------------------------------------------------------------------------- /img/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/snow.png -------------------------------------------------------------------------------- /img/stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/stone.png -------------------------------------------------------------------------------- /img/wreath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/wreath.png -------------------------------------------------------------------------------- /img/css_unit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/css_unit.png -------------------------------------------------------------------------------- /img/game_pre.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/game_pre.gif -------------------------------------------------------------------------------- /img/pepe-sad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/pepe-sad.png -------------------------------------------------------------------------------- /img/pwa_unit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/pwa_unit.png -------------------------------------------------------------------------------- /img/run_code.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/run_code.mov -------------------------------------------------------------------------------- /img/select_unit.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/select_unit.mov -------------------------------------------------------------------------------- /img/character_ork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_ork.png -------------------------------------------------------------------------------- /img/tuttorial_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/tuttorial_id.png -------------------------------------------------------------------------------- /img/tuttorial_run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/tuttorial_run.png -------------------------------------------------------------------------------- /img/character_magic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_magic.png -------------------------------------------------------------------------------- /img/character_nekr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_nekr.png -------------------------------------------------------------------------------- /img/character_null.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_null.png -------------------------------------------------------------------------------- /img/character_varvar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_varvar.png -------------------------------------------------------------------------------- /img/character_winter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_winter.png -------------------------------------------------------------------------------- /img/scripting_guide.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/scripting_guide.mov -------------------------------------------------------------------------------- /img/tuttorial_select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/tuttorial_select.png -------------------------------------------------------------------------------- /img/tuttorial_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/tuttorial_send.png -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.html' { 2 | const value: string; 3 | export default value; 4 | } -------------------------------------------------------------------------------- /img/character_palladin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lekzd/script_battle_game/HEAD/img/character_palladin.png -------------------------------------------------------------------------------- /src/common/battle/BattleSide.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum BattleSide { 3 | left = 'left', 4 | right = 'right' 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .data 3 | node_modules 4 | public 5 | package-lock.json 6 | archive.zip 7 | leaderboard.json 8 | -------------------------------------------------------------------------------- /src/common/helpers/color.ts: -------------------------------------------------------------------------------- 1 | 2 | export function color(hexColor: string) { 3 | return parseInt(hexColor.substring(1), 16); 4 | } -------------------------------------------------------------------------------- /server/models/IApiFullResponse.model.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IApiFullResponse { 3 | result: any; 4 | success: boolean; 5 | error?: string; 6 | } -------------------------------------------------------------------------------- /src/common/client/EMPTY_ARMY.ts: -------------------------------------------------------------------------------- 1 | 2 | export const EMPTY_ARMY = { 3 | 0: 'character_null', 4 | 1: 'character_null', 5 | 2: 'character_null', 6 | 3: 'character_null' 7 | }; -------------------------------------------------------------------------------- /src/common/helpers/font.ts: -------------------------------------------------------------------------------- 1 | 2 | export function font(size: number): string { 3 | return `${size}px -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,Arial,sans-serif` 4 | } -------------------------------------------------------------------------------- /src/common/RoomService.ts: -------------------------------------------------------------------------------- 1 | 2 | export class RoomService { 3 | 4 | get params(): URLSearchParams { 5 | return new URLSearchParams(location.hash.substr(1)); 6 | } 7 | 8 | get roomId(): string { 9 | return this.params.get('room'); 10 | } 11 | 12 | constructor() { 13 | } 14 | } -------------------------------------------------------------------------------- /src/common/battle/BattleUnitBattleState.ts: -------------------------------------------------------------------------------- 1 | import {BattleUnit} from "./BattleUnit"; 2 | 3 | export function getBattleState(unit: BattleUnit) { 4 | return { 5 | character: unit.character, 6 | health: unit.health, 7 | id: unit.id, 8 | x: unit.x, 9 | y: unit.y 10 | } 11 | } -------------------------------------------------------------------------------- /src/common/battle/BattleState.model.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum BattleState { 3 | wait = 'waiting', 4 | ready = 'ready', 5 | codding = 'codding', 6 | battle = 'battle', 7 | results = 'results', 8 | win = 'win', 9 | lost = 'lost', 10 | attention = 'attention', 11 | connectionClosed = 'connectionClosed' 12 | } -------------------------------------------------------------------------------- /src/App.ts: -------------------------------------------------------------------------------- 1 | import {MasterApp} from './master/MasterApp'; 2 | import {ClientApp} from './common/client/ClientApp'; 3 | import {AdminApp} from './admin/AdminApp'; 4 | import {IndexApp} from './admin/IndexApp'; 5 | 6 | (window as any).Client = ClientApp; 7 | (window as any).Master = MasterApp; 8 | (window as any).Admin = AdminApp; 9 | (window as any).Index = IndexApp; -------------------------------------------------------------------------------- /src/common/helpers/elementHasParent.ts: -------------------------------------------------------------------------------- 1 | export function elementHasParent(element: HTMLElement, parent: HTMLElement): boolean { 2 | let elementParent = element.parentNode; 3 | 4 | while (elementParent) { 5 | if (parent === elementParent) { 6 | return true; 7 | } 8 | 9 | elementParent = elementParent.parentNode; 10 | } 11 | 12 | return false; 13 | } -------------------------------------------------------------------------------- /server/models/RoomModel.ts: -------------------------------------------------------------------------------- 1 | import {Room} from "../Room"; 2 | import {IState} from "../../src/common/state.model"; 3 | 4 | export class RoomModel { 5 | title: string; 6 | state: Partial; 7 | watchersCount: number; 8 | 9 | constructor(room: Room) { 10 | this.title = room.title; 11 | this.state = room.state; 12 | this.watchersCount = room.watchersCount; 13 | } 14 | } -------------------------------------------------------------------------------- /src/common/helpers/getDistanceFactor.ts: -------------------------------------------------------------------------------- 1 | 2 | const MIN_DISTANCE = 5; 3 | const MAX_DISTANCE = 10; 4 | 5 | // возвращает значение от 0.5 до 1 6 | export function getDistanceFactor(x1: number, y1: number, x2: number, y2: number): number { 7 | const dx = x1 - x2; 8 | const dy = y1 - y2; 9 | const distance = Math.sqrt(dx * dx + dy * dy); 10 | const normalizedDistance = Math.min(Math.max(distance, MIN_DISTANCE), MAX_DISTANCE); 11 | 12 | return normalizedDistance / MAX_DISTANCE; 13 | } -------------------------------------------------------------------------------- /src/common/battle/views/ConnectionClosedView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {font} from '../../helpers/font'; 3 | 4 | export class ConnectionClosedView extends Phaser.Scene { 5 | 6 | constructor() { 7 | super({ 8 | key: 'connectionClosed' 9 | }); 10 | } 11 | 12 | create() { 13 | const text = this.add.text(200, 150, 'Соединение потеряно', { 14 | font: font(16), 15 | fill: '#cc0000' 16 | }); 17 | 18 | text.setOrigin(.5); 19 | } 20 | } -------------------------------------------------------------------------------- /server/clients/Guest.ts: -------------------------------------------------------------------------------- 1 | import {Client} from './Client'; 2 | import {NEVER, Observable} from "rxjs"; 3 | import {IMessage} from "../../src/common/WebsocketConnection"; 4 | 5 | export class Guest extends Client { 6 | 7 | get onMessage$(): Observable { 8 | return NEVER; 9 | } 10 | 11 | constructor() { 12 | super(); 13 | 14 | this.maxConnections = Infinity; 15 | } 16 | 17 | dispatchRoomsChanged() { 18 | this.send({ 19 | type: 'roomsChanged', 20 | data: null 21 | }) 22 | } 23 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "noImplicitAny": false, 5 | "removeComments": true, 6 | "experimentalDecorators": true, 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "jsxFactory": "h", 10 | "allowJs": true, 11 | "sourceMap": true, 12 | "lib": [ 13 | "es2015", 14 | "es2016.array.include", 15 | "es2017", 16 | "es2017.string", 17 | "es2017.object", 18 | "dom" 19 | ] 20 | }, 21 | "include": [ 22 | "src/**/*.ts", 23 | "src/**/*.tsx" 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ] 28 | } -------------------------------------------------------------------------------- /src/common/client/EnemyState.ts: -------------------------------------------------------------------------------- 1 | import {BattleSide} from "../battle/BattleSide"; 2 | import {Subject} from 'rxjs/index'; 3 | import {EMPTY_ARMY} from "./EMPTY_ARMY"; 4 | import {IPlayerState} from '../state.model'; 5 | 6 | export class EnemyState { 7 | 8 | name = ''; 9 | side: BattleSide; 10 | 11 | army = Object.assign({}, EMPTY_ARMY); 12 | 13 | change$ = new Subject(); 14 | 15 | set(newState: Partial) { 16 | Object.assign(this, {}, newState); 17 | 18 | this.change$.next(newState); 19 | } 20 | 21 | clear() { 22 | this.army = Object.assign({}, EMPTY_ARMY); 23 | } 24 | } -------------------------------------------------------------------------------- /server/storages/LeaderBoard.ts: -------------------------------------------------------------------------------- 1 | 2 | import {ISessionResult} from '../../src/common/battle/BattleSession'; 3 | import {AbstractFileBasedStorage} from './AbstractFileBasedStorage'; 4 | 5 | const filePath = './.data/leaderboard.json'; 6 | 7 | export class LeaderBoard extends AbstractFileBasedStorage { 8 | 9 | data = []; 10 | 11 | constructor() { 12 | super(); 13 | 14 | this.data = this.readFromFile(filePath); 15 | } 16 | 17 | write(sessionResult: ISessionResult) { 18 | const item = Object.assign({}, sessionResult, { 19 | time: Date.now() 20 | }); 21 | 22 | this.data.push(item); 23 | 24 | this.writeToFile(filePath, item) 25 | } 26 | } -------------------------------------------------------------------------------- /src/common/helpers/random.ts: -------------------------------------------------------------------------------- 1 | 2 | // 1993 Park-Miller LCG 3 | export function LCG(seed: number): () => number { 4 | return function() { 5 | seed = Math.imul(16807, seed) | 0 % 2147483647; 6 | 7 | return (seed & 2147483647) / 2147483648; 8 | } 9 | } 10 | 11 | let randomGenerator = LCG(0); 12 | 13 | export function getRandomSeed(seed: string): number { 14 | return [...seed].reduce((acc, curr) => acc + curr.charCodeAt(0), 0) % 2147483647; 15 | } 16 | 17 | export function setRandomSeed(seed: string) { 18 | const sedNumber = getRandomSeed(seed); 19 | 20 | randomGenerator = LCG(sedNumber); 21 | } 22 | 23 | export function random(): number { 24 | return randomGenerator(); 25 | } -------------------------------------------------------------------------------- /src/common/helpers/GraphNode.ts: -------------------------------------------------------------------------------- 1 | 2 | export class GraphNode { 3 | x: number; 4 | y: number; 5 | weight: number; 6 | parent: GraphNode; 7 | h: number; 8 | g: number; 9 | f: number; 10 | 11 | constructor(x: number, y: number, weight: number) { 12 | this.x = x; 13 | this.y = y; 14 | this.weight = weight; 15 | } 16 | 17 | getCost(fromNeighbor: GraphNode): number { 18 | if (fromNeighbor && (fromNeighbor.x !== this.x) && (fromNeighbor.y !== this.y)) { 19 | return this.weight * 1.41421 * 20; 20 | } 21 | return this.weight * 20; 22 | } 23 | 24 | isWall(): boolean { 25 | return this.weight === 0; 26 | } 27 | } -------------------------------------------------------------------------------- /src/common/battle/views/WinView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {font} from '../../helpers/font'; 3 | 4 | export class WinView extends Phaser.Scene { 5 | 6 | constructor() { 7 | super({ 8 | key: 'win' 9 | }); 10 | } 11 | 12 | create() { 13 | const graphics = this.add.graphics(); 14 | 15 | graphics.setPosition(0, 0); 16 | 17 | graphics.fillStyle(0x000000); 18 | graphics.setAlpha(0.6); 19 | graphics.fillRect(0, 0, 400, 300); 20 | 21 | const text = this.add.text(200, 150, 'Победа!', { 22 | font: font(26), 23 | fill: '#00cc00' 24 | }); 25 | 26 | text.setOrigin(.5); 27 | } 28 | } -------------------------------------------------------------------------------- /src/common/battle/views/LostView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {font} from '../../helpers/font'; 3 | 4 | export class LostView extends Phaser.Scene { 5 | 6 | constructor() { 7 | super({ 8 | key: 'lost' 9 | }); 10 | } 11 | 12 | create() { 13 | const graphics = this.add.graphics(); 14 | 15 | graphics.setPosition(0, 0); 16 | 17 | graphics.fillStyle(0x000000); 18 | graphics.setAlpha(0.6); 19 | graphics.fillRect(0, 0, 400, 300); 20 | 21 | const text = this.add.text(200, 150, 'Поражение', { 22 | font: font(26), 23 | fill: '#cc0000' 24 | }); 25 | 26 | text.setOrigin(.5); 27 | } 28 | } -------------------------------------------------------------------------------- /src/common/helpers/mergeDeep.ts: -------------------------------------------------------------------------------- 1 | 2 | function isObject(item) { 3 | return (item && typeof item === 'object' && !Array.isArray(item)); 4 | } 5 | 6 | export function mergeDeep(target, ...sources): T { 7 | if (!sources.length) return target; 8 | const source = sources.shift(); 9 | 10 | if (isObject(target) && isObject(source)) { 11 | for (const key in source) { 12 | if (isObject(source[key])) { 13 | if (!target[key]) Object.assign(target, { [key]: {} }); 14 | mergeDeep(target[key], source[key]); 15 | } else { 16 | Object.assign(target, { [key]: source[key] }); 17 | } 18 | } 19 | } 20 | 21 | return mergeDeep(target, ...sources); 22 | } -------------------------------------------------------------------------------- /src/admin/IndexApp.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import {RoomListComponent} from './RoomListComponent'; 3 | import {LeadersGridComponent} from './LeadersGridComponent'; 4 | import {WebsocketConnection} from '../common/WebsocketConnection'; 5 | import {Inject} from '../common/InjectDectorator'; 6 | 7 | export class IndexApp { 8 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 9 | 10 | constructor() { 11 | this.connection.registerAsGuest(); 12 | 13 | render(( 14 |
15 |

Комнаты

16 | 17 | 18 |

Список лидеров

19 | 20 |
21 | ), document.querySelector('.leaders')); 22 | } 23 | } -------------------------------------------------------------------------------- /src/master/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Зритель 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 | 21 | 26 | 27 | <% if (isDevServer) { %> 28 | 29 | <% } %> 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/right/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Правый игрок 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 |
19 | 20 | 25 | 26 | <% if (isDevServer) { %> 27 | 28 | <% } %> 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/admin/Wreath.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | 3 | interface IProps { 4 | place: number; 5 | } 6 | 7 | export class Wreath extends Component { 8 | 9 | render(props: IProps) { 10 | return ( 11 |
12 |
13 | {props.place} 14 |
15 |
16 |
17 | ) 18 | } 19 | 20 | private getPlaceClass(place: number): string { 21 | switch (place) { 22 | case 1: 23 | return 'wreath-gold'; 24 | case 2: 25 | return 'wreath-silver'; 26 | case 3: 27 | return 'wreath-bronze'; 28 | default: 29 | return 'wreath-other'; 30 | } 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/left/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Левый игрок 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 | 21 | 26 | 27 | <% if (isDevServer) { %> 28 | 29 | <% } %> 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/common/Environment.ts: -------------------------------------------------------------------------------- 1 | const PROD_URL = 'http://142.93.129.144'; 2 | const PROD_WS_URL = 'ws://142.93.129.144'; 3 | 4 | interface IEnvConfig { 5 | api: string; 6 | websocket: string; 7 | staticHost: string; 8 | baseUrl: string; 9 | } 10 | 11 | const localConfig = { 12 | api: 'http://localhost:1337/api', 13 | websocket: 'ws://localhost:1337', 14 | staticHost: 'http://localhost:8080', 15 | baseUrl: 'http://localhost:8080/public' 16 | }; 17 | 18 | const prodConfig = { 19 | api: `${PROD_URL}/api`, 20 | websocket: PROD_WS_URL, 21 | staticHost: PROD_URL, 22 | baseUrl: PROD_URL 23 | }; 24 | 25 | export class Environment { 26 | config: IEnvConfig; 27 | 28 | constructor() { 29 | if (location.hostname === 'localhost') { 30 | this.config = localConfig; 31 | } else { 32 | this.config = prodConfig; 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /server/clients/LeftPlayer.ts: -------------------------------------------------------------------------------- 1 | import {Client} from './Client'; 2 | import {Observable} from "rxjs"; 3 | import {IMessage} from "../../src/common/WebsocketConnection"; 4 | import {map, pluck} from "rxjs/operators"; 5 | 6 | export class LeftPlayer extends Client { 7 | 8 | code = ''; 9 | state = {}; 10 | 11 | get onMessage$(): Observable { 12 | return this.onUnsafeMessage$('state') 13 | .pipe( 14 | pluck('state', 'left'), 15 | map(left => ({state: {left}, type: 'state'})) 16 | ) 17 | } 18 | 19 | constructor() { 20 | super(); 21 | 22 | this.send({ 23 | type: 'state', 24 | data: 'battle' 25 | }); 26 | } 27 | 28 | dispatchSessionResult(sessionResult) { 29 | this.send({ 30 | type: 'endSession', 31 | data: {sessionResult} 32 | }); 33 | } 34 | } -------------------------------------------------------------------------------- /server/clients/RightPlayer.ts: -------------------------------------------------------------------------------- 1 | import {Client} from './Client'; 2 | import {Observable} from "rxjs"; 3 | import {IMessage} from "../../src/common/WebsocketConnection"; 4 | import {map, pluck} from "rxjs/operators"; 5 | 6 | export class RightPlayer extends Client { 7 | 8 | code = ''; 9 | state = {}; 10 | 11 | get onMessage$(): Observable { 12 | return this.onUnsafeMessage$('state') 13 | .pipe( 14 | pluck('state', 'right'), 15 | map(right => ({state: {right}, type: 'state'})) 16 | ) 17 | } 18 | 19 | constructor() { 20 | super(); 21 | 22 | this.send({ 23 | type: 'state', 24 | data: 'battle' 25 | }); 26 | } 27 | 28 | dispatchSessionResult(sessionResult) { 29 | this.send({ 30 | type: 'endSession', 31 | data: {sessionResult} 32 | }); 33 | } 34 | } -------------------------------------------------------------------------------- /src/admin/PlayerLink.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | 3 | interface IComponentState { 4 | } 5 | 6 | interface IProps { 7 | link: string; 8 | } 9 | 10 | export class PlayerLink extends Component { 11 | 12 | state: IComponentState = {}; 13 | 14 | private input: HTMLInputElement; 15 | 16 | render(props: IProps, state: IComponentState) { 17 | return ( 18 |
19 |
Ссылка:
20 |
21 | this.input = input} 23 | onFocus={_ => this.onFocus()} 24 | value={props.link} 25 | /> 26 |
27 |
28 | ); 29 | } 30 | 31 | private onFocus() { 32 | this.input.select(); 33 | } 34 | } -------------------------------------------------------------------------------- /src/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Heroes of Tinkoff and JS 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 | 29 | 30 | <% if (isDevServer) { %> 31 | 32 | <% } %> 33 | 34 | 35 | -------------------------------------------------------------------------------- /server/clients/Master.ts: -------------------------------------------------------------------------------- 1 | import {Client} from './Client'; 2 | import {Observable} from "rxjs"; 3 | import {IMessage} from "../../src/common/WebsocketConnection"; 4 | import {first} from "rxjs/operators"; 5 | import {map, pluck} from 'rxjs/internal/operators'; 6 | 7 | export class Master extends Client { 8 | 9 | get onMessage$(): Observable { 10 | return this.onUnsafeMessage$('sendWinner') 11 | .pipe( 12 | pluck('sessionResult'), 13 | map(sessionResult => ({sessionResult, type: 'sendWinner'})) 14 | ); 15 | } 16 | 17 | constructor() { 18 | super(); 19 | 20 | this.maxConnections = Infinity; 21 | 22 | this.send({ 23 | type: 'state', 24 | data: 'wait' 25 | }); 26 | } 27 | 28 | dispatchSessionResult(sessionResult) { 29 | this.send({ 30 | type: 'endSession', 31 | data: {sessionResult} 32 | }) 33 | } 34 | } -------------------------------------------------------------------------------- /server/clients/Admin.ts: -------------------------------------------------------------------------------- 1 | import {Client} from './Client'; 2 | import * as ws from 'ws'; 3 | import {NEVER, Observable} from "rxjs"; 4 | import {IMessage} from "../../src/common/WebsocketConnection"; 5 | 6 | export class Admin extends Client { 7 | 8 | get onMessage$(): Observable { 9 | return NEVER; 10 | } 11 | 12 | private adminToken = Math.random().toString(36).substr(2); 13 | 14 | constructor() { 15 | super(); 16 | 17 | this.maxConnections = Infinity; 18 | } 19 | 20 | setConnection(connection: ws) { 21 | super.setConnection(connection); 22 | 23 | this.send({ 24 | type: 'adminToken', 25 | data: this.adminToken 26 | }) 27 | } 28 | 29 | checkToken(token: string): boolean { 30 | return this.adminToken === token; 31 | } 32 | 33 | dispatchRoomsChanged() { 34 | this.send({ 35 | type: 'roomsChanged', 36 | data: null 37 | }) 38 | } 39 | } -------------------------------------------------------------------------------- /src/common/WebComponent.ts: -------------------------------------------------------------------------------- 1 | 2 | export abstract class WebComponent extends HTMLElement { 3 | state: Partial = {}; 4 | 5 | setState(newState: Partial) { 6 | this.state = Object.assign(this.state, newState); 7 | 8 | if (this.shouldChange(this.state)) { 9 | this.onChanged(this.state); 10 | 11 | if (this.shouldRender(this.state)) { 12 | this.innerHTML = this.render(this.state); 13 | 14 | this.afterRender(this.state); 15 | } 16 | } 17 | } 18 | 19 | abstract render(state: Partial): string; 20 | 21 | renderList(list: any[], callback: (item: any) => string): string { 22 | return list.map(item => callback(item)).join(''); 23 | } 24 | 25 | afterRender(state: Partial) {} 26 | 27 | shouldRender(state: Partial): boolean { 28 | return true; 29 | } 30 | 31 | shouldChange(state: Partial): boolean { 32 | return true; 33 | } 34 | 35 | onChanged(state: Partial) {} 36 | } -------------------------------------------------------------------------------- /src/common/state.model.ts: -------------------------------------------------------------------------------- 1 | import {BattleSide} from './battle/BattleSide'; 2 | import {WinnerSide} from './battle/BattleSession'; 3 | import {BattleState} from './battle/BattleState.model'; 4 | 5 | export interface IEditorState { 6 | code: string; 7 | scrollX: number; 8 | scrollY: number; 9 | cursorX: number; 10 | cursorY: number; 11 | } 12 | 13 | export interface IArmyState { 14 | 0: string; 15 | 1: string; 16 | 2: string; 17 | 3: string; 18 | } 19 | 20 | export interface IPlayerState { 21 | name: string; 22 | side: BattleSide; 23 | isReady: boolean; 24 | isConnected: boolean; 25 | 26 | army: IArmyState; 27 | 28 | editor: IEditorState; 29 | } 30 | 31 | export interface IState { 32 | mode: BattleState; 33 | createTime: number; 34 | endTime: number; 35 | 36 | roomId: string; 37 | roomTitle: string; 38 | 39 | left: IPlayerState; 40 | right: IPlayerState; 41 | 42 | damage: { 43 | left: number; 44 | right: number; 45 | }, 46 | winner: WinnerSide; 47 | } -------------------------------------------------------------------------------- /src/common/documentation/AccordionSection.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h, RenderableProps} from 'preact'; 2 | 3 | interface IComponentState { 4 | opened: boolean; 5 | } 6 | 7 | interface IProps { 8 | header: string; 9 | opened: boolean; 10 | } 11 | 12 | export class AccordionSection extends Component { 13 | 14 | state: IComponentState = { 15 | opened: this.props.opened 16 | }; 17 | 18 | componentDidMount() { 19 | 20 | } 21 | 22 | render(props: RenderableProps, state: IComponentState) { 23 | return ( 24 |
25 |

this.onClick()}> 26 | {props.header} 27 |

28 |
29 | 30 | {props.children} 31 | 32 |
33 |
34 | ); 35 | } 36 | 37 | private onClick() { 38 | this.setState({opened: !this.state.opened}); 39 | } 40 | } -------------------------------------------------------------------------------- /src/common/client/ClientState.ts: -------------------------------------------------------------------------------- 1 | import {BattleSide} from "../battle/BattleSide"; 2 | import {Subject} from 'rxjs'; 3 | import {Inject} from '../InjectDectorator'; 4 | import {WebsocketConnection} from '../WebsocketConnection'; 5 | import {EMPTY_ARMY} from "./EMPTY_ARMY"; 6 | import {IEditorState, IPlayerState} from '../state.model'; 7 | 8 | export class ClientState implements IPlayerState{ 9 | 10 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 11 | 12 | createTime: number; 13 | roomId: string; 14 | roomTitle: string; 15 | 16 | editor: IEditorState; 17 | 18 | name = ''; 19 | side: BattleSide; 20 | isReady = false; 21 | isConnected = true; 22 | 23 | army = Object.assign({}, EMPTY_ARMY); 24 | 25 | change$ = new Subject(); 26 | 27 | setFromServer(newState: Partial) { 28 | Object.assign(this, {}, newState); 29 | } 30 | 31 | set(newState: Partial) { 32 | Object.assign(this, {}, newState); 33 | 34 | this.change$.next(newState); 35 | } 36 | 37 | clear() { 38 | this.army = Object.assign({}, EMPTY_ARMY); 39 | } 40 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Heroes of Tinkoff and JS 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 | 23 | Fork me on GitHub 24 | 25 | 26 | 31 | 32 | <% if (isDevServer) { %> 33 | 34 | <% } %> 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/common/battle/BattleGame.ts: -------------------------------------------------------------------------------- 1 | import {ISessionResult} from "./BattleSession"; 2 | import {BattleState} from './BattleState.model'; 3 | import {BattleGameScreen} from './BattleGameScreen'; 4 | 5 | export class BattleGame { 6 | 7 | stateParams: any = {}; 8 | currentState: BattleState; 9 | 10 | private game: BattleGameScreen; 11 | 12 | register(game: BattleGameScreen) { 13 | this.game = game; 14 | } 15 | 16 | setState(newState: BattleState, stateParams: any = {}) { 17 | if (this.currentState === newState) { 18 | return; 19 | } 20 | 21 | this.game.setGameState(BattleState.wait, newState); 22 | 23 | this.currentState = newState; 24 | this.stateParams = stateParams || {}; 25 | } 26 | 27 | showResults(sessionResult: ISessionResult) { 28 | this.setState(BattleState.results, sessionResult); 29 | 30 | this.game.showResults(sessionResult); 31 | } 32 | 33 | showWinnerScreen(sessionResult: ISessionResult) { 34 | this.setState(BattleState.win, sessionResult); 35 | } 36 | 37 | showLoseScreen(sessionResult: ISessionResult) { 38 | this.setState(BattleState.lost, sessionResult); 39 | } 40 | } -------------------------------------------------------------------------------- /src/common/characters/AnimationsCreator.ts: -------------------------------------------------------------------------------- 1 | 2 | const actions = [ 3 | { 4 | name: 'spellcast', 5 | frames: 7 6 | }, 7 | { 8 | name: 'thrust', 9 | frames: 8 10 | }, 11 | { 12 | name: 'walk', 13 | frames: 9 14 | }, 15 | { 16 | name: 'slash', 17 | frames: 6 18 | }, 19 | { 20 | name: 'shoot', 21 | frames: 13 22 | }, 23 | { 24 | name: 'idle', 25 | frames: 1 26 | } 27 | ]; 28 | 29 | const turns = ['top', 'left', 'bottom', 'right']; 30 | 31 | export class AnimationsCreator { 32 | create(phaserAnims: any, characterName: string) { 33 | 34 | actions.forEach(({name, frames}) => { 35 | turns.forEach(turn => { 36 | phaserAnims.create({ 37 | key: `${characterName}_${name}_${turn}`, 38 | frames: phaserAnims.generateFrameNames(characterName, { 39 | prefix: `${name}_${turn}_`, 40 | end: frames - 1 41 | }), 42 | frameRate: 15, 43 | repeat: -1 44 | }); 45 | }) 46 | }); 47 | } 48 | } -------------------------------------------------------------------------------- /src/common/documentation/BasicJS.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const BasicJS = () => ( 4 |
5 |
6 |
    7 |
  1. 8 |
    Переменные
    9 |
    10 |
    {`
    11 |     stepsCount = 10
    12 |     enemyIds = ['ie', '$', 'eval', 'dart']`}
    13 | 
    14 |
    15 |
  2. 16 |
  3. 17 |
    Цикл
    18 |
    19 |
    {`
    20 |     for (var i = 0; i < 10; i++) {
    21 |        shoot('ie')
    22 |     }`}
    23 | 
    24 |
    25 |
  4. 26 |
  5. 27 |
    Функция
    28 |
    29 |
    {`
    30 |     function attack(id) {
    31 |         if (isShooter()) {
    32 |            shoot(id)
    33 |         }
    34 |         if (isInfantry()) {
    35 |             goToEnemyAndHit(id)
    36 |         }
    37 |     }
    38 | 
    39 |     attack('ie')`}
    40 | 
    41 |
    42 |
  6. 43 |
44 |
45 |
46 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Script Battle Game 2 | 3 | [Presentation video](https://youtu.be/LfgaRFnmkhk) 4 | 5 | ## Local launch 6 | 7 | 1. `npm run start` 8 | 2. `npm run server` 9 | 3. open `localhost:8080/public` in browser 10 | 4. create or edit file `./.data/.env` with data: `ADMIN_PASSWORD=admin` 11 | 5. open `localhost:8080/public/admin` in browser 12 | 6. login with `admin/${ADMIN_PASSWORD}` credentials, create room & share link 13 | 14 | ## How to make your own server 15 | 16 | ### 1: `src/common/Environment.ts` 17 | 18 | Edit constants below: 19 | 20 | `PROD_URL` - http(s) server URL; 21 | 22 | `PROD_WS_URL` - ws(s) server URL; 23 | 24 | ### 2: `.env file` 25 | 26 | Create `.data` directory in root 27 | Create file `.env` with data: 28 | ``` 29 | ADMIN_PASSWORD=admin 30 | ``` 31 | you can set any admin password to access admin panel to create new rooms 32 | 33 | ### 3: `package.json` (optional) 34 | 35 | Edit script `deploy` from `scripts` section 36 | to make your own deploy command for your server 37 | 38 | ### 4: `npm run release` 39 | 40 | makes archive to deploy on server 41 | 42 | ### 5: launch server 43 | 44 | Use [ts-node](https://www.npmjs.com/package/ts-node) or [PM2](http://pm2.keymetrics.io/) to launch `./server/index.ts` file 45 | -------------------------------------------------------------------------------- /src/common/battle/UnitsStack.ts: -------------------------------------------------------------------------------- 1 | import {BattleUnit} from './BattleUnit'; 2 | 3 | export class UnitsStack { 4 | 5 | activeUnit: BattleUnit; 6 | 7 | private alive: BattleUnit[] = []; 8 | 9 | init(units: BattleUnit[]) { 10 | this.alive = units.sort((a: BattleUnit, b: BattleUnit) => { 11 | if (a.character.speed < b.character.speed) { 12 | return 1; 13 | } 14 | 15 | return -1; 16 | }); 17 | 18 | this.alive.forEach(unit => { 19 | unit.hasTurn = true; 20 | }); 21 | } 22 | 23 | next() { 24 | this.activeUnit = this.alive.shift(); 25 | 26 | this.activeUnit.hasTurn = false; 27 | 28 | this.alive.push(this.activeUnit); 29 | 30 | if (this.isRoundEnd()) { 31 | this.newRound(); 32 | } 33 | } 34 | 35 | newRound() { 36 | this.clearDiedUnits(); 37 | 38 | this.alive.forEach(unit => { 39 | unit.hasTurn = true; 40 | }); 41 | } 42 | 43 | private isRoundEnd(): boolean { 44 | return !this.alive.some(unit => unit.hasTurn); 45 | } 46 | 47 | private clearDiedUnits() { 48 | this.alive = this.alive.filter(unit => unit.health > 0) 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /src/common/InjectDectorator.ts: -------------------------------------------------------------------------------- 1 | 2 | const instances = new Map(); 3 | 4 | function tryToInstantiateClassForTarget(classLocator: any, targetName: string) { 5 | if (!instances.has(classLocator)) { 6 | try { 7 | instances.set(classLocator, new classLocator()); 8 | } catch(e) { 9 | console.error(`Failed to instantiate class ${classLocator} for target ${targetName}`); 10 | throw e; 11 | } 12 | } 13 | } 14 | 15 | export function Inject(classLocator: any) { 16 | return function(target: any, name: string, descriptor?: PropertyDescriptor) { 17 | if (!name) { 18 | throw new Error('@Inject() shoud be applied to class attribute only'); 19 | } 20 | 21 | tryToInstantiateClassForTarget(classLocator, target.name); 22 | 23 | Object.defineProperty(target, name, { 24 | get: () => instances.get(classLocator) 25 | }) 26 | 27 | } 28 | } 29 | 30 | export const setInject = (classLocator: any, value: any) => { 31 | instances.set(classLocator, value); 32 | }; 33 | 34 | export const inject = (classLocator: any): T => { 35 | tryToInstantiateClassForTarget(classLocator, 'static'); 36 | 37 | return instances.get(classLocator); 38 | }; -------------------------------------------------------------------------------- /server/storages/AbstractFileBasedStorage.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from "path"; 3 | import * as mkdirp from 'mkdirp'; 4 | 5 | export abstract class AbstractFileBasedStorage { 6 | 7 | protected writeToFile(filePath: string, item: any) { 8 | const data = this.readFromFile(filePath); 9 | 10 | data.push(item); 11 | 12 | this.writeAllData(filePath, data); 13 | } 14 | 15 | protected writeAllData(filePath: string, data: any[]) { 16 | fs.writeFile(filePath, JSON.stringify(data), (err) => { 17 | if(err) { 18 | return console.log(err); 19 | } 20 | }); 21 | } 22 | 23 | protected readFromFile(filePath: string): any[] { 24 | this.createFileIfNotExists(filePath); 25 | 26 | const contents = fs.readFileSync(filePath, 'utf8'); 27 | let data = []; 28 | 29 | try { 30 | data = JSON.parse(contents.toString()); 31 | } catch (e) { 32 | data = []; 33 | } 34 | 35 | return data; 36 | } 37 | 38 | private createFileIfNotExists(filePath: string) { 39 | if (fs.existsSync(filePath)) { 40 | return; 41 | } 42 | 43 | mkdirp.sync(path.dirname(filePath)); 44 | 45 | fs.writeFileSync(filePath, '[]'); 46 | } 47 | } -------------------------------------------------------------------------------- /src/common/helpers/AsyncSequence.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export class AsyncSequence { 4 | private all: Function[]; 5 | private counter: number; 6 | private globalResolve: Function; 7 | promise: Promise; 8 | 9 | constructor(all) { 10 | this.all = all; 11 | this.counter = this.all.filter(item => typeof item === "function").length; 12 | this.promise = new Promise(globalResolve => { 13 | this.globalResolve = globalResolve; 14 | this.promise = >this.all.reduce(this.runCallback.bind(this), Promise.resolve(true)); 15 | }); 16 | } 17 | 18 | private onPromise(promise: Promise, callback: Function): Promise { 19 | promise = callback(); 20 | 21 | this.counter--; 22 | 23 | if (this.counter === 0) { 24 | this.globalResolve(true); 25 | } 26 | 27 | return promise; 28 | } 29 | 30 | private runCallback(promise: Promise, item: Function | Array): Promise { 31 | const callback = Array.isArray(item) 32 | ? AsyncSequence.from.bind(this, item) 33 | : item; 34 | 35 | return promise.then(this.onPromise.bind(this, promise, callback)); 36 | } 37 | 38 | static from(list: any[]): Promise { 39 | return (new AsyncSequence(list)).promise; 40 | } 41 | } -------------------------------------------------------------------------------- /src/common/battle/views/AttentionView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {interval, Observable} from 'rxjs'; 3 | import {map, take} from 'rxjs/internal/operators'; 4 | import {font} from '../../helpers/font'; 5 | 6 | export class AttentionView extends Phaser.Scene { 7 | 8 | constructor() { 9 | super({ 10 | key: 'attention' 11 | }); 12 | } 13 | 14 | get timer$(): Observable { 15 | let time = 3; 16 | 17 | return interval(1000) 18 | .pipe( 19 | take(4), 20 | map(() => time--) 21 | ) 22 | } 23 | 24 | create() { 25 | const graphics = this.add.graphics(); 26 | 27 | graphics.setPosition(0, 0); 28 | 29 | graphics.fillStyle(0x000000); 30 | graphics.setAlpha(0.6); 31 | graphics.fillRect(0, 0, 400, 300); 32 | 33 | this.add.text(200, 110, 'Внимание на главный экран!', { 34 | font: font(16), 35 | fill: '#ccc349' 36 | }).setOrigin(.5); 37 | 38 | const timeText = this.add.text(200, 160, '3', { 39 | font: font(26), 40 | fill: '#ccc349' 41 | }); 42 | 43 | timeText.setOrigin(.5); 44 | 45 | this.timer$.subscribe(seconds => { 46 | timeText.setText(seconds.toString()); 47 | }) 48 | } 49 | } -------------------------------------------------------------------------------- /src/common/client/ClientDisplay.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {BattleSide} from "../battle/BattleSide"; 3 | import {IPlayerState} from "../state.model"; 4 | 5 | interface IComponentState { 6 | } 7 | 8 | interface IProps { 9 | side: BattleSide; 10 | playerState: IPlayerState; 11 | } 12 | 13 | const EMPTY_NAME = '--Без имени--'; 14 | 15 | export class ClientDisplay extends Component { 16 | 17 | state: IComponentState = { 18 | }; 19 | 20 | componentDidMount() { 21 | } 22 | 23 | render(props: IProps, state: IComponentState) { 24 | const playerState: Partial = props.playerState || {}; 25 | let status = 'оффлайн'; 26 | 27 | if (playerState.isConnected) { 28 | status = playerState.isReady ? 'готов' : 'пишет код'; 29 | } 30 | 31 | return ( 32 |
33 |
34 |
35 | {status} 36 |
37 |
38 | {playerState.name || EMPTY_NAME} 39 |
40 |
41 | ); 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/common/console/BattleConsole.tsx: -------------------------------------------------------------------------------- 1 | import {ConsoleService, IConsoleLine} from "./ConsoleService"; 2 | import {Inject} from "../InjectDectorator"; 3 | import {Component, h} from 'preact'; 4 | 5 | interface IConsoleState { 6 | lines: IConsoleLine[]; 7 | } 8 | 9 | interface IProps { 10 | } 11 | 12 | export class BattleConsole extends Component { 13 | 14 | @Inject(ConsoleService) private consoleService: ConsoleService; 15 | 16 | state: IConsoleState = { 17 | lines: [] 18 | }; 19 | 20 | componentDidMount() { 21 | this.consoleService 22 | .subscribe(message => { 23 | this.setState({ 24 | lines: [message, ...this.state.lines] 25 | }); 26 | }); 27 | } 28 | 29 | render(props: IProps, state: IConsoleState) { 30 | return ( 31 |
32 |
33 | {state.lines.map(line => ( 34 |
35 |
{line.source}:
36 |
{line.text}
37 |
38 | ))} 39 |
40 |
41 | ); 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/player/editor/SandboxAutocomplete.ts: -------------------------------------------------------------------------------- 1 | import * as ace from "brace"; 2 | import {getUnitApi} from '../../common/codeSandbox/getUnitApi'; 3 | 4 | interface IAutocompleteItem { 5 | name: string; 6 | value: string; 7 | score: number; 8 | meta: string; 9 | } 10 | 11 | type AutocompleteCallback = (a: any, words: IAutocompleteItem[]) => void; 12 | 13 | export class SandboxAutocomplete { 14 | 15 | private functionsList: IAutocompleteItem[] = []; 16 | 17 | constructor() { 18 | const functions = getUnitApi.toString().match(/(\w+\((\w+(\,\s?)?)*\))/ig); 19 | const unitApi = getUnitApi({}, []); 20 | 21 | this.functionsList = functions 22 | .filter(name => typeof unitApi[name.match(/^\w+/)[0]] === 'function') 23 | .map(name => { 24 | return { 25 | name: name.match(/^\w+/)[0], 26 | value: name, 27 | meta: 'api', 28 | score: 10 29 | } 30 | }); 31 | } 32 | 33 | getCompletions(editor: ace.Editor, session: any, pos: number, prefix: string, callback: AutocompleteCallback) { 34 | if (prefix.length === 0) { 35 | callback(null, []); 36 | 37 | return; 38 | } 39 | 40 | callback(null, this.functionsList.filter(item => item.value.startsWith(prefix))); 41 | } 42 | } -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as cors from 'cors'; 3 | import * as argsParser from 'args-parser'; 4 | import * as expressWs from 'express-ws'; 5 | import * as bodyParser from 'body-parser'; 6 | import * as cookieParser from 'cookie-parser'; 7 | import * as passport from 'passport'; 8 | import {SocketMiddleware} from './SocketMiddleware'; 9 | import {ApiController} from "./ApiController"; 10 | 11 | const {app} = expressWs(express()); 12 | const args = argsParser(process.argv); 13 | 14 | expressWs(app); 15 | 16 | app.use(express.static('public')); 17 | app.use(bodyParser.json()); 18 | app.use(cookieParser('secret')); 19 | app.use(passport.initialize()); 20 | app.use(passport.session()); 21 | 22 | const corsOptions = { 23 | origin: args.port === 80 ? '' : 'http://localhost:8080', 24 | methods: ['GET', 'PUT', 'POST', 'DELETE'], 25 | allowedHeaders: ['Content-Type', 'Authorization'], 26 | credentials: true, 27 | optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204 28 | }; 29 | 30 | app.use('/api', cors(corsOptions)); 31 | app.options('*', cors(corsOptions)); 32 | 33 | const {router} = new ApiController(express.Router()); 34 | 35 | app.use('/api', router); 36 | 37 | app.ws('/', (ws, request) => { 38 | 39 | new SocketMiddleware(ws); 40 | 41 | }); 42 | 43 | app.use('*', (request, response) => { 44 | response.send('OK'); 45 | }); 46 | 47 | app.listen(args.port || 80); -------------------------------------------------------------------------------- /src/common/console/ConsoleService.ts: -------------------------------------------------------------------------------- 1 | import {BehaviorSubject} from "rxjs/internal/BehaviorSubject"; 2 | 3 | export enum MessageType { 4 | VM = 'vm', 5 | Runtime = 'runtime', 6 | Unexpected = 'unexpected', 7 | Service = '😸', 8 | Log = 'log', 9 | Info = '👍', 10 | Warn = 'warn', 11 | Error = 'error', 12 | Success = 'success' 13 | } 14 | 15 | export interface IConsoleLine { 16 | source: MessageType; 17 | text: string; 18 | } 19 | 20 | export class ConsoleService extends BehaviorSubject { 21 | 22 | constructor() { 23 | super({ 24 | source: MessageType.Service, 25 | text: 'Привет! Это консоль, здесь будет отладочная информация, ошибки и важные сообщения' 26 | }) 27 | } 28 | 29 | vmLog(...attributes) { 30 | this.next({ 31 | source: MessageType.VM, 32 | text: attributes.join() 33 | }) 34 | } 35 | 36 | serviceLog(...attributes) { 37 | this.next({ 38 | source: MessageType.Service, 39 | text: attributes.join() 40 | }) 41 | } 42 | 43 | infoLog(...attributes) { 44 | this.next({ 45 | source: MessageType.Info, 46 | text: attributes.join() 47 | }) 48 | } 49 | 50 | runtimeLog(...attributes) { 51 | this.next({ 52 | source: MessageType.Runtime, 53 | text: attributes.join() 54 | }) 55 | } 56 | } -------------------------------------------------------------------------------- /src/common/battle/views/WaitingView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {Inject} from "../../InjectDectorator"; 3 | import {CharactersList} from "../../characters/CharactersList"; 4 | import {WebsocketConnection} from "../../WebsocketConnection"; 5 | import {Environment} from '../../Environment'; 6 | import {font} from '../../helpers/font'; 7 | 8 | export class WaitingView extends Phaser.Scene { 9 | 10 | @Inject(Environment) private environment: Environment; 11 | @Inject(CharactersList) private charactersList: CharactersList; 12 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 13 | 14 | constructor() { 15 | super({ 16 | key: 'waiting' 17 | }); 18 | } 19 | 20 | preload () { 21 | this.load.setBaseURL(this.environment.config.staticHost); 22 | 23 | this.load.image('arrow', '/img/arrow.png'); 24 | this.load.image('snow', '/img/snow.png'); 25 | this.load.image('fire', '/img/fire.png'); 26 | this.load.image('stone', '/img/stone.png'); 27 | 28 | this.charactersList.load(this.load); 29 | 30 | const text = this.add.text(200, 150, 'Соединение с сервером...', { 31 | font: font(16), 32 | align: 'right', 33 | fill: '#00ff00' 34 | }); 35 | 36 | text.setOrigin(0.5); 37 | } 38 | 39 | create() { 40 | 41 | if (!this.connection.isMaster) { 42 | setTimeout(() => { 43 | this.scene.switch('battle'); 44 | }, 1000); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/common/client/ClientComponent.ts: -------------------------------------------------------------------------------- 1 | import {IPlayerState} from '../state.model'; 2 | import {Inject} from '../InjectDectorator'; 3 | import {BattleSide} from '../battle/BattleSide'; 4 | import {WebsocketConnection} from '../WebsocketConnection'; 5 | 6 | const EMPTY_NAME = '--Без имени--'; 7 | 8 | export class ClientComponent { 9 | 10 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 11 | 12 | constructor(private container: HTMLElement, private side: BattleSide) { 13 | this.update({ 14 | isConnected: false, 15 | isReady: false, 16 | name: '' 17 | }); 18 | 19 | this.connection.onState$(side) 20 | .subscribe(state => { 21 | this.update(state); 22 | }) 23 | } 24 | 25 | render(state: Partial): string { 26 | let status = 'оффлайн'; 27 | 28 | if (state.isConnected) { 29 | status = state.isReady ? 'готов' : 'пишет код'; 30 | } 31 | 32 | return ` 33 |
34 |
35 |
36 | ${status} 37 |
38 |
39 | ${state.name || EMPTY_NAME} 40 |
41 |
42 | `; 43 | } 44 | 45 | private update(state: Partial) { 46 | this.container.innerHTML = this.render(state); 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/admin/PromptModal.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {Subject} from 'rxjs/index'; 3 | 4 | interface IProps { 5 | title: string; 6 | template: any; 7 | onSubmit$: Subject; 8 | } 9 | 10 | interface IComponentState { 11 | 12 | } 13 | 14 | export class PromptModal extends Component { 15 | private form: any; 16 | private dialog: any; 17 | 18 | componentDidMount() { 19 | this.dialog.showModal(); 20 | } 21 | 22 | render(props: IProps) { 23 | return ( 24 | this.dialog = ref}> 25 |
26 |
this.form = ref}> 27 | 30 | 33 | 36 |
37 |
38 |
39 | ) 40 | } 41 | 42 | onSubmit = (event) => { 43 | event.preventDefault(); 44 | 45 | this.dialog.close(); 46 | 47 | const formData = new FormData(this.form); 48 | const object = {}; 49 | 50 | formData.forEach(function(value, key){ 51 | object[key] = value; 52 | }); 53 | 54 | this.props.onSubmit$.next(object); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "script_battle_game", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "start": "webpack-dev-server", 9 | "server": "ts-node ./server/index.ts --port=1337", 10 | "release": "npm run build && npm run release:copy && npm run release:zip", 11 | "release:copy": "cp -R ./img ./public && cp style.css ./public", 12 | "release:zip": "zip -r archive.zip . -x *.git* *.map *.idea* *.data* node_modules/**\\*", 13 | "deploy": "scp archive.zip root@142.93.129.144:game/archive.zip", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@types/passport": "^0.4.6", 20 | "args-parser": "^1.1.0", 21 | "body-parser": "^1.18.3", 22 | "brace": "^0.11.1", 23 | "cookie-parser": "^1.4.3", 24 | "cors": "^2.8.4", 25 | "diff": "^3.5.0", 26 | "dotenv": "^6.0.0", 27 | "express": "^4.16.3", 28 | "express-ws": "^4.0.0", 29 | "mkdirp": "^0.5.1", 30 | "passport": "^0.4.0", 31 | "passport-cookie": "^1.0.6", 32 | "passport-local": "^1.0.0", 33 | "phaser": "^3.11.0", 34 | "preact": "^8.3.1", 35 | "rxjs": "^6.2.2", 36 | "ts-node": "^7.0.1", 37 | "websocket": "^1.0.26" 38 | }, 39 | "devDependencies": { 40 | "@types/express": "^4.16.0", 41 | "@types/express-ws": "^3.0.0", 42 | "@types/node": "^10.9.1", 43 | "@types/websocket": "0.0.39", 44 | "html-webpack-plugin": "^3.2.0", 45 | "raw-loader": "^0.5.1", 46 | "ts-loader": "^4.4.2", 47 | "typescript": "^3.0.1", 48 | "webpack": "^4.16.3", 49 | "webpack-cli": "^3.1.0", 50 | "webpack-closure-compiler": "^2.1.6", 51 | "webpack-dev-server": "^3.1.10" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/common/roomTimer/RoomTimer.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {Subject, timer} from 'rxjs/index'; 3 | import {takeUntil} from 'rxjs/internal/operators'; 4 | 5 | interface IComponentState { 6 | time: number; 7 | } 8 | 9 | interface IProps { 10 | startTime: number; 11 | endTime: number; 12 | } 13 | 14 | export class RoomTimer extends Component { 15 | 16 | state: IComponentState = { 17 | time: 0 18 | }; 19 | 20 | private timer$ = timer(0, 1000); 21 | private unmount$ = new Subject(); 22 | 23 | componentDidMount() { 24 | this.timer$ 25 | .pipe(takeUntil(this.unmount$)) 26 | .subscribe(() => { 27 | this.setState({ 28 | time: this.getEndTime() - (this.props.startTime || Date.now()) 29 | }); 30 | }); 31 | } 32 | 33 | componentWillUnmount() { 34 | this.unmount$.next(); 35 | } 36 | 37 | render(props: IProps, state: IComponentState) { 38 | let timerClass = 'color-green'; 39 | 40 | if (state.time > (1000 * 60 * 12)) { 41 | timerClass = 'color-yellow'; 42 | } 43 | 44 | if (state.time > (1000 * 60 * 15)) { 45 | timerClass = 'color-red'; 46 | } 47 | 48 | return ( 49 |
50 | {this.formatTime(state.time)} 51 |
52 | ); 53 | } 54 | 55 | private formatTime(time: number): string { 56 | const minutes = Math.floor(time / (1000 * 60)); 57 | const seconds = Math.floor(time / 1000) % 60; 58 | 59 | return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 60 | } 61 | 62 | private getEndTime(): number { 63 | return this.props.endTime || Date.now(); 64 | } 65 | } -------------------------------------------------------------------------------- /src/common/battle/BulletDrawer.ts: -------------------------------------------------------------------------------- 1 | export enum BulletType { 2 | arrow, snow, fire, stone 3 | } 4 | 5 | export class BulletDrawer { 6 | 7 | private drawers = new Map(); 8 | 9 | constructor(private scene: Phaser.Scene) { 10 | 11 | this.drawers.set(BulletType.arrow, this.getArrowGraphics); 12 | this.drawers.set(BulletType.snow, this.getSnowGraphics); 13 | this.drawers.set(BulletType.fire, this.getFireGraphics); 14 | this.drawers.set(BulletType.stone, this.getStoneGraphics); 15 | 16 | } 17 | 18 | get(key: BulletType): Phaser.GameObjects.Image { 19 | return this.drawers.get(key).call(this); 20 | } 21 | 22 | getArrowGraphics(): Phaser.GameObjects.Image { 23 | const graphics = this.scene.add.image(0, 0, 'arrow'); 24 | 25 | return graphics; 26 | } 27 | 28 | getSnowGraphics(): Phaser.GameObjects.Image { 29 | const image = this.scene.add.image(0, 0, 'snow'); 30 | 31 | this.scene.tweens.add({ 32 | targets: image, 33 | angle: 1800, 34 | delay: 50, 35 | repeat: 3 36 | }); 37 | 38 | image.setBlendMode(Phaser.BlendModes.ADD); 39 | 40 | return image; 41 | } 42 | 43 | getFireGraphics(): Phaser.GameObjects.Image { 44 | const graphics = this.scene.add.image(0, 0, 'fire'); 45 | 46 | this.scene.tweens.add({ 47 | targets: graphics, 48 | angle: 1800, 49 | delay: 50, 50 | repeat: 3 51 | }); 52 | 53 | return graphics; 54 | } 55 | 56 | getStoneGraphics(): Phaser.GameObjects.Image { 57 | const graphics = this.scene.add.image(0, 0, 'stone'); 58 | 59 | this.scene.tweens.add({ 60 | targets: graphics, 61 | angle: 1400, 62 | delay: 50, 63 | repeat: 3 64 | }); 65 | 66 | return graphics; 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/common/documentation/BasicFAQ.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const BasicFAQ = () => ( 4 |
5 |
6 |
    7 |
  1. 8 |
    9 | Соберите отряд 10 |
    11 |
    12 |
    14 |
    15 | Есть три класса юнитов: маги, стрелки и пехотинцы 16 |
    17 |
  2. 18 |
  3. 19 |
    20 | Напишите скрипт для своей армии 21 |
    22 |
    23 |
    25 |
    26 | Код – чистый JS, исполняется последовательно для каждого юнита 27 |
    28 |
  4. 29 |
  5. 30 |
    31 | Нажмите "Готово!" 32 |
    33 |
    34 | 35 |
    36 |
    37 | Сражение между игроками начнется только после того как оба будут готовы 38 |
    39 |
  6. 40 |
41 |
42 | 43 |
44 |
    45 |
  • Очередность хода зависит от скорости юнита, быстрые ходят первыми
  • 46 |
  • Победитель определяется либо после полной победы одной из сторон, либо по очкам после окончания выполнения кода
  • 47 |
48 |
49 |
50 | ) 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/common/codeSandbox/getUnitApi.ts: -------------------------------------------------------------------------------- 1 | import {IAction} from './CodeSandbox'; 2 | 3 | export function getUnitApi(unit: any, actions: IAction[]) { 4 | class UnitApi { 5 | 6 | goTo(x: number, y: number) { 7 | actions.push({action: 'goTo', x: x, y: y}); 8 | } 9 | 10 | relativeGoTo(x: number, y: number) { 11 | actions.push({action: 'goTo', x: x + unit.x, y: y + unit.y}); 12 | } 13 | 14 | goToEnemyAndHit(id: string) { 15 | actions.push({action: 'goToEnemyAndHit', id: id}); 16 | } 17 | 18 | shoot(id: string) { 19 | actions.push({action: 'shoot', id: id}); 20 | } 21 | 22 | spell(id: string) { 23 | actions.push({action: 'spell', id: id}); 24 | } 25 | 26 | // heal(id: string) { 27 | // actions.push({action: 'heal', id: id}); 28 | // } 29 | 30 | say(text: string) { 31 | actions.push({action: 'say', text: text}); 32 | } 33 | 34 | attackRandom() { 35 | actions.push({action: 'attackRandom'}); 36 | } 37 | 38 | isShooter(): boolean { 39 | return unit.character.type === 'shooting'; 40 | } 41 | 42 | isMagician(): boolean { 43 | return unit.character.type === 'magic'; 44 | } 45 | 46 | isInfantry(): boolean { 47 | return unit.character.type === 'melee'; 48 | } 49 | 50 | // isAlive(): boolean { 51 | // return unit.health > 0; 52 | // } 53 | 54 | // getHealth(): number { 55 | // return unit.health; 56 | // } 57 | 58 | // getID(): string { 59 | // return unit.id; 60 | // } 61 | 62 | // getX(): number { 63 | // return unit.x; 64 | // } 65 | // 66 | // getY(): number { 67 | // return unit.y; 68 | // } 69 | 70 | is(id: string): boolean { 71 | return unit.id.toLowerCase() === `${id}`.toLowerCase(); 72 | } 73 | } 74 | 75 | return new UnitApi(); 76 | } -------------------------------------------------------------------------------- /img/push.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /server/AuthController.ts: -------------------------------------------------------------------------------- 1 | import * as passport from 'passport'; 2 | import * as dotenv from 'dotenv'; 3 | import {Strategy as CookieStrategy} from 'passport-cookie'; 4 | import {Strategy as LocalStrategy} from 'passport-local'; 5 | import {Client} from "./clients/Client"; 6 | import {Admin} from "./clients/Admin"; 7 | 8 | type doneFunction = (error: any, result: Client) => any; 9 | 10 | const config = dotenv.config({path: './.data/.env'}); 11 | 12 | const localConfig = { 13 | }; 14 | 15 | const cookieConfig = { 16 | session: false, 17 | signed: true 18 | }; 19 | 20 | const admin = new Admin(); 21 | 22 | export class AuthController { 23 | 24 | sessions = new Map(); 25 | lastToken = ''; 26 | 27 | constructor() { 28 | passport.serializeUser((user: any, done: any) => { 29 | done(null, user.login); 30 | }); 31 | 32 | passport.deserializeUser((id, done) => { 33 | done(null, admin); 34 | }); 35 | } 36 | 37 | authenticate(): any { 38 | return passport.authenticate('local', localConfig); 39 | } 40 | 41 | checkAuth(): any { 42 | return passport.authenticate('cookie', cookieConfig); 43 | } 44 | 45 | localMiddleware(): LocalStrategy { 46 | return new LocalStrategy((username: string, password: string, done: doneFunction) => { 47 | const result = this.findUser(username, password); 48 | 49 | this.lastToken = Math.random().toString(36).substr(2); 50 | 51 | if (result) { 52 | this.sessions.set(this.lastToken, result); 53 | } 54 | 55 | return done(null, result); 56 | }); 57 | } 58 | 59 | cookiesMiddleware(): CookieStrategy { 60 | return new CookieStrategy(cookieConfig, (token: string, done: doneFunction) => { 61 | const result = this.sessions.get(token); 62 | 63 | return done(null, result); 64 | }); 65 | } 66 | 67 | private findUser(username: string, password: string): any { 68 | if (username === 'admin' && password === config.parsed.ADMIN_PASSWORD) { 69 | return {isAdmin: true, login: 'admin'}; 70 | } 71 | 72 | return; 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /src/player/toolbar/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {Documentation} from "../../common/documentation/Documentation"; 3 | import {NEVER, of, Subject, timer} from "rxjs"; 4 | import {SelectWindow} from "./SelectWindow"; 5 | import {IPlayerState} from "../../common/state.model"; 6 | import {first, switchMap} from "../../../node_modules/rxjs/internal/operators"; 7 | import {Inject} from "../../common/InjectDectorator"; 8 | import {WebsocketConnection} from "../../common/WebsocketConnection"; 9 | 10 | interface IComponentState { 11 | } 12 | 13 | interface IProps { 14 | playerState: Partial; 15 | onSetReady: () => any; 16 | onRunCode: () => any; 17 | } 18 | 19 | export class Toolbar extends Component { 20 | 21 | state: IComponentState = { 22 | 23 | }; 24 | 25 | private helpButtonClick$ = new Subject(); 26 | 27 | componentDidMount() { 28 | } 29 | 30 | render(props: IProps, state: IComponentState) { 31 | const isMac = navigator.platform.toUpperCase().includes('MAC'); 32 | 33 | return ( 34 |
35 | 36 | 40 | 41 | 42 | 43 | 49 | 55 | 56 | 57 | 58 |
59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /src/common/battle/views/ResultsView.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import {ISessionResult, WinnerSide} from '../BattleSession'; 3 | import {BattleFieldModel} from '../BattleFieldModel'; 4 | import {Inject} from '../../InjectDectorator'; 5 | import {BattleSide} from '../BattleSide'; 6 | import {BattleUnit} from '../BattleUnit'; 7 | import {font} from '../../helpers/font'; 8 | 9 | export class ResultsView extends Phaser.Scene { 10 | 11 | @Inject(BattleFieldModel) private battleFieldModel: BattleFieldModel; 12 | 13 | private text: Phaser.GameObjects.Text; 14 | 15 | constructor() { 16 | super({ 17 | key: 'results' 18 | }); 19 | } 20 | 21 | create() { 22 | const graphics = this.add.graphics(); 23 | 24 | graphics.setPosition(0, 0); 25 | 26 | graphics.fillStyle(0x000000); 27 | graphics.setAlpha(0.6); 28 | graphics.fillRect(0, 0, 400, 300); 29 | 30 | this.text = this.add.text(200, 150, 'Результаты', { 31 | font: font(16), 32 | align: 'right', 33 | fill: '#00ff00' 34 | }); 35 | 36 | this.text.setOrigin(0.5); 37 | } 38 | 39 | setResults(results: ISessionResult) { 40 | const isLeftWins = results.winner === WinnerSide.left; 41 | const isRightWins = results.winner === WinnerSide.right; 42 | let units = []; 43 | let result = ''; 44 | 45 | if (isLeftWins) { 46 | units = this.getAliveUnits(BattleSide.left); 47 | result = `Победил левый`; 48 | } 49 | 50 | if (isRightWins) { 51 | units = this.getAliveUnits(BattleSide.right); 52 | result = 'Победил правый'; 53 | } 54 | 55 | if (!result) { 56 | result = 'Ничья'; 57 | } 58 | 59 | this.text.setText(result); 60 | this.makeCelebration(units); 61 | } 62 | 63 | private getAliveUnits(side: BattleSide): BattleUnit[] { 64 | return this.battleFieldModel.units.filter(unit => { 65 | return unit.health > 0 && unit.side === side; 66 | }) 67 | } 68 | 69 | private makeCelebration(units: BattleUnit[]) { 70 | units.forEach(unit => { 71 | unit.makeCelebration(); 72 | }); 73 | } 74 | } -------------------------------------------------------------------------------- /src/common/documentation/HowCodeWorks.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const HowCodeWorks = () => ( 4 |
5 |
6 |
    7 |
  1. Весь код исполняется 1 раз ДО начала битвы
  2. 8 |
  3. Один и тот же код исполняется для каждого юнита
  4. 9 |
  5. далее юниты ходят согласно получившимуся сценарию
  6. 10 |
11 |
12 |

Надо ставить условия, чтобы разный код исполнялся для разных юнитов

13 |
14 | 15 |
16 |
17 | Можно ставить условия по ID: 18 |
19 |
20 |
21 |
22 | {`if (is('CSS')) {
23 |     ...
24 | }`}
25 | 
26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 |
34 | 35 |
36 |
37 | Или по классу юнита: 38 |
39 |
40 |
41 |
42 | {`if (isShooter()) {
43 |     ...
44 | }`}
45 | 
46 |
47 |
48 | 49 | 50 | 51 |
52 |
53 |
54 |
55 |
56 | ) -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const isDevServer = process.argv.find(v => v.includes('webpack-dev-server')); 4 | const ClosureCompilerPlugin = require('webpack-closure-compiler'); 5 | 6 | function getHtmlWebpackConfig(directory) { 7 | const outputPath = path.join('public', directory, 'index.html'); 8 | 9 | return { 10 | template: path.resolve('src', directory, 'index.html'), 11 | templateParameters: {isDevServer}, 12 | filename: isDevServer ? outputPath : path.join(__dirname, outputPath) 13 | } 14 | } 15 | 16 | const plugins = [ 17 | new HtmlWebpackPlugin(getHtmlWebpackConfig('master')), 18 | new HtmlWebpackPlugin(getHtmlWebpackConfig('left')), 19 | new HtmlWebpackPlugin(getHtmlWebpackConfig('right')), 20 | new HtmlWebpackPlugin(getHtmlWebpackConfig('admin')), 21 | new HtmlWebpackPlugin(getHtmlWebpackConfig('')) 22 | ]; 23 | 24 | if (!isDevServer) { 25 | plugins.push( 26 | new ClosureCompilerPlugin({ 27 | compiler: { 28 | language_in: 'ECMASCRIPT6', 29 | language_out: 'ECMASCRIPT6', 30 | compilation_level: 'ADVANCED' 31 | }, 32 | concurrency: 3, 33 | }) 34 | ); 35 | } 36 | 37 | module.exports = function(env = {}) { 38 | const config = { 39 | mode: isDevServer ? 'development' : 'production', 40 | devtool: isDevServer ? 'source-map': false, 41 | entry: './src/App.ts', 42 | output: { 43 | path: path.join(__dirname, 'public'), 44 | filename: 'script.js' 45 | }, 46 | plugins: plugins, 47 | resolve: { 48 | extensions: ['.ts', '.tsx', '.js'] 49 | }, 50 | module: { 51 | rules: [ 52 | { 53 | test: /\.tsx?$/, 54 | use: [ 55 | {loader: 'ts-loader'} 56 | ] 57 | }, 58 | { 59 | test: /\.template\.html?$/, 60 | use: [ 61 | {loader: 'raw-loader'} 62 | ] 63 | } 64 | ] 65 | } 66 | }; 67 | 68 | return config; 69 | }; -------------------------------------------------------------------------------- /src/common/helpers/Maybe.ts: -------------------------------------------------------------------------------- 1 | 2 | class MaybeMonad { 3 | 4 | private value: any = null; 5 | 6 | constructor(any: any) { 7 | if (any instanceof MaybeMonad) { 8 | this.value = any.value; 9 | } else if (typeof any === 'function') { 10 | throw Error('function clone'); 11 | } else { 12 | this.value = any; 13 | } 14 | } 15 | 16 | isEmpty(): boolean { 17 | return (this.value === null) || (this.value === undefined); 18 | } 19 | 20 | get(): any { 21 | return this.value; 22 | } 23 | 24 | getOrElse(elseVal): any { 25 | if (this.isEmpty()) { 26 | return elseVal; 27 | } else { 28 | return this.value; 29 | } 30 | } 31 | 32 | toInt(): MaybeMonad { 33 | this.value = parseInt(this.value); 34 | if (isNaN(this.value)) { 35 | this.value = null; 36 | } 37 | return this; 38 | } 39 | 40 | toString(): MaybeMonad { 41 | this.value = String(this.value); 42 | return this; 43 | } 44 | 45 | toBoolean(): MaybeMonad { 46 | if (this.value) { 47 | this.value = !~['false', '0'].indexOf(String(this.value).toLowerCase()); 48 | } else { 49 | this.value = false; 50 | } 51 | return this; 52 | } 53 | 54 | evaluate(...params): MaybeMonad { 55 | this.value = (this.value instanceof Function) 56 | ? this.value.call(...params) 57 | : this.value; 58 | return this; 59 | } 60 | 61 | pluck(path: string): MaybeMonad { 62 | try { 63 | this.value = path.split('.').reduce(((result, key) => result && result[key]), this.value); 64 | } catch (error) { 65 | console.error('Maybe.pluck', error); 66 | } 67 | return this; 68 | } 69 | 70 | map(fn: (value: any) => any): MaybeMonad { 71 | try { 72 | if (Array.isArray(this.value)) { 73 | this.value.map(fn); 74 | } else { 75 | this.value = fn(this.value); 76 | } 77 | } catch (error) { 78 | console.error('Maybe.map', error); 79 | } 80 | return this; 81 | } 82 | } 83 | 84 | export const Maybe = any => new MaybeMonad(any); -------------------------------------------------------------------------------- /src/player/gameDebug/GameDebug.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {filter, first} from "rxjs/internal/operators"; 3 | import {RoomTimer} from "../../common/roomTimer/RoomTimer"; 4 | import {WebsocketConnection} from "../../common/WebsocketConnection"; 5 | import {Inject} from "../../common/InjectDectorator"; 6 | import {ClientDisplay} from "../../common/client/ClientDisplay"; 7 | import {BattleSide} from "../../common/battle/BattleSide"; 8 | import {BattleGameScreen} from "../../common/battle/BattleGameScreen"; 9 | import {BattleConsole} from "../../common/console/BattleConsole"; 10 | import {IState} from "../../common/state.model"; 11 | import { Observable } from 'rxjs/internal/Observable'; 12 | import {Subject} from 'rxjs/index'; 13 | import {BattleState} from '../../common/battle/BattleState.model'; 14 | 15 | interface IComponentState { 16 | createTime: number 17 | } 18 | 19 | interface IProps { 20 | state: Partial; 21 | runCode$: Observable<[string, string]>; 22 | } 23 | 24 | export class GameDebug extends Component { 25 | 26 | state: IComponentState = { 27 | createTime: null 28 | }; 29 | 30 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 31 | 32 | private setState$ = new Subject(); 33 | 34 | componentDidMount() { 35 | this.connection.onState$('createTime') 36 | .pipe( 37 | filter(createTime => !!createTime), 38 | first() 39 | ) 40 | .subscribe(createTime => { 41 | this.setState({createTime}); 42 | }); 43 | } 44 | 45 | render(props: IProps, state: IComponentState) { 46 | return ( 47 |
48 |
49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 |
58 | ); 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /src/common/documentation/UsefulTips.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const UsefulTips = () => ( 4 |
5 |
6 |
    7 |
  1. 8 |
    Каждому свое
    9 |

    10 | Разделяйте логику юнитов по классу персонажа 11 |

    12 |
    {
    13 | `    if (isShooter()) {
    14 |         shoot('ie')
    15 |     }
    16 |     if (isMagician()) {
    17 |         spell('ie')
    18 |     }
    19 |     if (isInfantry()) {
    20 |         goToEnemyAndHit('ie')
    21 |     }`}
    22 | 
    23 | 24 |

    или по id

    25 |
    {
    26 | `    if (is('eval')) {
    27 |         spell('ie')
    28 |     }`}
    29 | 
    30 |
  2. 31 | 32 |
  3. 33 |
    Стрелки – ближе, маги – дальше
    34 |

    Чтобы достичь максимальной атаки:

    35 |
    {
    36 | `    // стрелки должны стоять максиум за 5 клеток от противника
    37 |     if (isShooter()) {
    38 |         relativeGoTo(5, 0)
    39 |     }
    40 |     // маги – минимум за 10
    41 |     if (isMagician()) {
    42 |         relativeGoTo(-2, -1)
    43 |     }`}
    44 | 
    45 | 46 |
  4. 47 | 48 |
  5. 49 |
    Слишком много действий
    50 |

    Если юнит противника погиб, а действия на его атаку еще остались...

    51 |
    {
    52 | `     for (i = 0; i < 10; i++) {
    53 |          goToEnemyAndHit('dart')
    54 |      }
    55 |      for (i = 0; i < 10; i++) {
    56 |          goToEnemyAndHit('css')
    57 |      }`}
    58 | 
    59 |

    ...юнит будет пропускать ходы пока не дойдет до действий атаки еще живого противника, если они еще есть

    60 |
  6. 61 | 62 |
  7. 63 |
    Списки противников
    64 |

    Бывает так, что противник все время меняет юнитов

    65 |
    {
    66 | `    ids = ['ie', '$', 'dart']
    67 | 
    68 |     ids.forEach(id => {
    69 |         shoot(id)
    70 |         shoot(id)
    71 |     })`}
    72 | 
    73 |

    потому их проще записать в массив и менять вместе с ним

    74 |
  8. 75 | 76 |
77 | 78 |
79 |
80 | ) -------------------------------------------------------------------------------- /src/common/battle/BattleStatistics.ts: -------------------------------------------------------------------------------- 1 | import {BattleSide} from "./BattleSide"; 2 | import {color} from "../helpers/color"; 3 | import {font} from '../helpers/font'; 4 | 5 | const MAX_DAMAGE = 500; 6 | 7 | export class BattleStatistics { 8 | 9 | private damageText: Phaser.GameObjects.Text; 10 | private damageLine: Phaser.GameObjects.Graphics; 11 | private container: any; 12 | 13 | constructor(private scene: Phaser.Scene, side: BattleSide) { 14 | 15 | const left = side === BattleSide.left ? 10 : 390; 16 | 17 | this.container = this.scene.add.container(left, 3); 18 | 19 | this.damageText = this.generateDamageText(); 20 | this.damageLine = this.generateDamageLine(); 21 | 22 | if (side === BattleSide.right) { 23 | this.damageText.setOrigin(1, 0); 24 | this.damageLine.setX(-345); 25 | } 26 | 27 | this.container.add(this.damageText); 28 | this.container.add(this.damageLine); 29 | } 30 | 31 | setDamage(value: number) { 32 | this.damageText.setText(value.toString()); 33 | this.updateDamageLine(value); 34 | } 35 | 36 | private generateDamageText(): Phaser.GameObjects.Text { 37 | return this.scene.add.text(0, -1, '0', { 38 | font: font(16), 39 | fill: '#faff39' 40 | }); 41 | } 42 | 43 | private generateDamageLine(): Phaser.GameObjects.Graphics { 44 | const graphics = this.scene.add.graphics(); 45 | 46 | graphics.setX(195); 47 | 48 | return graphics; 49 | } 50 | 51 | private updateDamageLine(value: number) { 52 | const width = 150; 53 | const healthWith = Math.max(0, width - Math.round(width * (value / MAX_DAMAGE))); 54 | 55 | this.damageLine.clear(); 56 | 57 | this.damageLine.lineStyle(1, color('#11cc14'), 1); 58 | this.damageLine.strokeRect(0, 6, width, 2); 59 | 60 | this.damageLine.fillStyle(this.getColor(value), 1); 61 | this.damageLine.fillRect(0, 6, healthWith, 2); 62 | } 63 | 64 | private getColor(value: number): number { 65 | const percent = 100 - ((value / MAX_DAMAGE) * 100); 66 | 67 | if (percent > 75) { 68 | return color('#00FF00'); 69 | } 70 | 71 | if (percent > 50) { 72 | return color('#e9ff23'); 73 | } 74 | 75 | if (percent > 25) { 76 | return color('#ffa133'); 77 | } 78 | 79 | return color('#ff3131'); 80 | } 81 | } -------------------------------------------------------------------------------- /src/common/helpers/HexagonalGraph.ts: -------------------------------------------------------------------------------- 1 | import {Graph} from "./Graph"; 2 | import {GraphNode} from "./GraphNode"; 3 | 4 | export class HexagonalGraph extends Graph { 5 | constructor(width: number, height: number) { 6 | super(width, height); 7 | } 8 | 9 | neighborByAngle({x, y}, angle: number) { 10 | const ODD = y % 2; 11 | 12 | if (angle === 0) { 13 | if (ODD) { 14 | return this.getSouthWest(x, y); 15 | } else { 16 | return this.getSouth(x, y); 17 | } 18 | } 19 | 20 | if (angle === 1) { 21 | if (ODD) { 22 | return this.getSouth(x, y); 23 | } else { 24 | return this.getSouthEast(x, y); 25 | } 26 | } 27 | 28 | if (angle === 2) { 29 | return this.getEast(x, y); 30 | } 31 | 32 | if (angle === 3) { 33 | if (ODD) { 34 | return this.getNorth(x, y); 35 | } else { 36 | return this.getNorthEast(x, y); 37 | } 38 | } 39 | 40 | if (angle === 4) { 41 | if (ODD) { 42 | return this.getNorthWest(x, y); 43 | } else { 44 | return this.getNorth(x, y); 45 | } 46 | } 47 | 48 | if (angle === 5) { 49 | return this.getWest(x, y); 50 | } 51 | } 52 | 53 | neighbors({x, y}: GraphNode): GraphNode[] { 54 | let cell; 55 | const ret = []; 56 | const ODD = y % 2; 57 | 58 | if (cell = this.getWest(x, y)) { 59 | ret.push(cell); 60 | } 61 | 62 | if (cell = this.getEast(x, y)) { 63 | ret.push(cell); 64 | } 65 | 66 | if (cell = this.getSouth(x, y)) { 67 | ret.push(cell); 68 | } 69 | 70 | if (cell = this.getNorth(x, y)) { 71 | ret.push(cell); 72 | } 73 | 74 | if (ODD) { 75 | if (cell = this.getSouthWest(x, y)) { 76 | ret.push(cell); 77 | } 78 | 79 | if (cell = this.getNorthWest(x, y)) { 80 | ret.push(cell); 81 | } 82 | 83 | } else { 84 | if (cell = this.getSouthEast(x, y)) { 85 | ret.push(cell); 86 | } 87 | 88 | if (cell = this.getNorthEast(x, y)) { 89 | ret.push(cell); 90 | } 91 | } 92 | 93 | return ret; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /server/storages/RoomStorage.ts: -------------------------------------------------------------------------------- 1 | import {Room} from "../Room"; 2 | import * as ws from 'ws'; 3 | import {ConnectionsStorage} from './ConnectionsStorage'; 4 | import {Inject} from '../../src/common/InjectDectorator'; 5 | import {AbstractFileBasedStorage} from './AbstractFileBasedStorage'; 6 | 7 | const filePath = './.data/rooms.json'; 8 | 9 | export class RoomStorage extends AbstractFileBasedStorage { 10 | 11 | private rooms = new Map(); 12 | private connections = new WeakMap(); 13 | 14 | @Inject(ConnectionsStorage) private guestConnectionsStorage: ConnectionsStorage; 15 | 16 | constructor() { 17 | super(); 18 | 19 | const data = this.readFromFile(filePath); 20 | 21 | data.forEach(({id, title, state}) => { 22 | const room = new Room(title); 23 | 24 | room.state = state; 25 | 26 | this.rooms.set(id, room); 27 | }) 28 | } 29 | 30 | saveState() { 31 | const data = []; 32 | 33 | this.rooms.forEach((room, id) => { 34 | data.push({ 35 | id, 36 | title: room.title, 37 | state: room.state 38 | }) 39 | }) 40 | 41 | this.writeAllData(filePath, data); 42 | } 43 | 44 | createNew(id: string, title: string) { 45 | this.rooms.set(id, new Room(title)); 46 | 47 | this.guestConnectionsStorage.dispatchRoomsChanged(); 48 | } 49 | 50 | delete(roomId: string) { 51 | const room = this.get(roomId); 52 | 53 | room.closeConnections(); 54 | this.rooms.delete(roomId); 55 | 56 | this.guestConnectionsStorage.dispatchRoomsChanged(); 57 | } 58 | 59 | get(roomId: string): Room { 60 | const room = this.rooms.get(roomId); 61 | 62 | if (!room) { 63 | throw Error(`No room with id ${roomId}`); 64 | } 65 | 66 | return room; 67 | } 68 | 69 | getAll(): {[key: string]: Room} { 70 | const result = {}; 71 | 72 | this.rooms.forEach((room, name) => { 73 | result[name] = room; 74 | }); 75 | 76 | return result; 77 | } 78 | 79 | addConnection(connection: ws, room: Room) { 80 | this.connections.set(connection, room); 81 | } 82 | 83 | getRoomByConnection(connection: ws): Room { 84 | return this.connections.get(connection); 85 | } 86 | 87 | reloadRoomSession(roomId: string) { 88 | const room = this.get(roomId); 89 | 90 | room.reloadSession(); 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /src/player/toolbar/UnitItem.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {CharacterType, ICharacterConfig} from "../../common/characters/CharactersList"; 3 | 4 | interface IComponentState { 5 | } 6 | 7 | interface IProps { 8 | characterConfig: ICharacterConfig, 9 | onChoose: () => any 10 | } 11 | 12 | export class UnitItem extends Component { 13 | 14 | state: IComponentState = {}; 15 | 16 | componentDidMount() { 17 | } 18 | 19 | render({characterConfig, onChoose}: IProps, state: IComponentState) { 20 | return ( 21 |
22 |
23 |
24 |
25 | {characterConfig.id} 26 | {this.getCharacterType(characterConfig)} 27 |
28 | {characterConfig.title} 29 |
30 |
31 |
атака / защита
32 |
33 | ближний {characterConfig.mellee.attack.max} / {characterConfig.mellee.defence.max} 34 |
35 |
36 | стрельба {characterConfig.shoot.attack.max} / {characterConfig.shoot.defence.max} 37 |
38 |
39 | магия {characterConfig.magic.attack.max} / {characterConfig.magic.defence.max} 40 |
41 |
42 | скорость {characterConfig.speed} 43 |
44 |
45 | 46 |
47 |
48 | ); 49 | } 50 | 51 | private getCharacterType(config: ICharacterConfig): string { 52 | switch (config.type) { 53 | case CharacterType.magic: 54 | return 'Маг'; 55 | case CharacterType.shooting: 56 | return 'Стрелок'; 57 | case CharacterType.melee: 58 | return 'Пехотинец'; 59 | } 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /src/common/helpers/Grid.ts: -------------------------------------------------------------------------------- 1 | 2 | type iteratorCallback = (item: T, x: number, y: number) => R; 3 | 4 | export class Grid { 5 | size = 0; 6 | 7 | private data: T[] = []; 8 | private byteSize = 0; 9 | 10 | constructor(size: number) { 11 | this.size = size; 12 | this.byteSize = Math.ceil(Math.log2(this.size)); 13 | } 14 | 15 | private getKey(x: number, y: number): number { 16 | return (y << this.byteSize) + x; 17 | } 18 | 19 | set(x: number, y: number, data: T): Grid { 20 | let key = this.getKey(x, y); 21 | if ((key >= 0) && (x < this.size) && (y < this.size)) { 22 | this.data[key] = data; 23 | } 24 | return this; 25 | } 26 | 27 | get(x: number, y: number): T { 28 | return this.data[this.getKey(x, y)]; 29 | } 30 | 31 | private iteratee(callback: iteratorCallback, item: T, key: number) { 32 | const y = key >> this.byteSize; 33 | const x = key - (y << this.byteSize); 34 | 35 | callback(item, x, y); 36 | } 37 | 38 | forEach(callback: iteratorCallback): Grid { 39 | this.data.forEach(this.iteratee.bind(this, callback)); 40 | return this; 41 | } 42 | 43 | filter(filterFunction: iteratorCallback): Grid { 44 | let result = new Grid(this.size); 45 | for (let key = 0; key < this.data.length; key++) { 46 | let y; 47 | let item = this.data[key]; 48 | if (item === undefined) { continue; } 49 | let x = key - ((y = key >> this.byteSize) << this.byteSize); 50 | if (filterFunction(item, x, y)) { 51 | result.set(x, y, item); 52 | } 53 | } 54 | return result; 55 | } 56 | 57 | has(x: number, y: number): boolean { 58 | return this.data[this.getKey(x, y)] !== undefined; 59 | } 60 | 61 | clear(): Grid { 62 | this.data.length = 0; 63 | return this; 64 | } 65 | 66 | toString(): string { 67 | let grid = ''; 68 | for (let y = 0, end = this.size, asc = 0 <= end; asc ? y <= end : y >= end; asc ? y++ : y--) { 69 | let row = ''; 70 | for (let x = 0, end1 = this.size, asc1 = 0 <= end1; asc1 ? x <= end1 : x >= end1; asc1 ? x++ : x--) { 71 | let num: any = Number(this.get(x, y)); 72 | if (isNaN(num)) { num = '.'; } 73 | row += num.toString() + ','; 74 | } 75 | grid += `\ 76 | [${row}]`; 77 | } 78 | return grid; 79 | } 80 | 81 | getRawData(): T[] { 82 | return this.data; 83 | } 84 | 85 | setRawData(data: T[]) { 86 | this.data = data; 87 | } 88 | } -------------------------------------------------------------------------------- /src/master/CodeDisplay.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {IEditorState, IPlayerState} from '../common/state.model'; 3 | import {Maybe} from '../common/helpers/Maybe'; 4 | import {EMPTY_ARMY} from '../common/client/EMPTY_ARMY'; 5 | import {AceEditor} from "../player/editor/AceEditor"; 6 | 7 | interface IComponentState { 8 | } 9 | 10 | interface IProps { 11 | class: string; 12 | playerState: IPlayerState; 13 | } 14 | 15 | export class CodeDisplay extends Component { 16 | 17 | state: IComponentState = {}; 18 | 19 | private previous = ''; 20 | private current = ''; 21 | private editor: AceEditor; 22 | 23 | componentDidMount() { 24 | this.onStateChanged(this.props.playerState); 25 | } 26 | 27 | componentDidUpdate() { 28 | this.onStateChanged(this.props.playerState); 29 | } 30 | 31 | render(props: IProps, state: IComponentState) { 32 | const army = Maybe(props.playerState).pluck('army').getOrElse(EMPTY_ARMY); 33 | 34 | return ( 35 |
36 |
37 |
38 | {Object.keys(army).map(index => { 39 | const key = army[index]; 40 | 41 | return ( 42 |
43 |
44 |
45 | ); 46 | })} 47 |
48 |
49 | this.editor = ref} 50 | onChange={() => null} 51 | onCtrlEnter={() => null} 52 | onScroll={() => null} 53 | readonly={true} 54 | /> 55 |
56 |
57 |
58 | ); 59 | } 60 | 61 | private setCode(code: string) { 62 | this.previous = this.current; 63 | this.current = code; 64 | 65 | this.editor.setValue(code); 66 | } 67 | 68 | private onStateChanged(playerState: IPlayerState) { 69 | const editorState = Maybe(playerState).pluck('editor').getOrElse({ 70 | scrollX: 0, 71 | scrollY: 0, 72 | code: '' 73 | }) as IEditorState; 74 | 75 | this.editor.scroll(editorState.scrollX || 0, editorState.scrollY || 0); 76 | 77 | if (editorState.code !== undefined) { 78 | this.setCode(editorState.code); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/master/MasterApp.tsx: -------------------------------------------------------------------------------- 1 | import {IMessage, WebsocketConnection} from '../common/WebsocketConnection'; 2 | import {Inject, setInject} from '../common/InjectDectorator'; 3 | import {LeftArmy} from "../left/LeftArmy"; 4 | import {EMPTY_ARMY} from "../common/client/EMPTY_ARMY"; 5 | import {RightArmy} from "../right/RightArmy"; 6 | import {IState} from '../common/state.model'; 7 | import {catchError, filter, first, map, switchMap} from 'rxjs/internal/operators'; 8 | import {RoomService} from "../common/RoomService"; 9 | import {ApiService} from '../common/ApiService'; 10 | import {PromptService} from '../admin/PromptService'; 11 | import {Environment} from '../common/Environment'; 12 | import {render, h} from 'preact'; 13 | import {MasterScreen} from './MasterScreen'; 14 | import {Observable} from 'rxjs/index'; 15 | 16 | export class MasterApp { 17 | 18 | @Inject(ApiService) private apiService: ApiService; 19 | @Inject(Environment) private environment: Environment; 20 | @Inject(RoomService) private roomService: RoomService; 21 | @Inject(PromptService) private promptService: PromptService; 22 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 23 | 24 | constructor() { 25 | this.connection.registerAsMaster(this.roomService.roomId); 26 | 27 | setInject(LeftArmy, Object.assign({}, EMPTY_ARMY)); 28 | setInject(RightArmy, Object.assign({}, EMPTY_ARMY)); 29 | 30 | this.connection.onMessage$.subscribe(message => { 31 | this.onMessage(message) 32 | }); 33 | 34 | this.connection.onClose$.subscribe(() => { 35 | this.checkRoomExistenceAndShowError('Данная комната больше не существует'); 36 | }); 37 | 38 | this.connection.onState$() 39 | .pipe(first()) 40 | .subscribe(state => { 41 | render(( 42 | 43 | ), document.querySelector('.master')); 44 | }); 45 | 46 | this.checkRoomExistenceAndShowError('Данная комната не существует'); 47 | } 48 | 49 | private onMessage(message: IMessage) { 50 | if (message.type === 'newSession') { 51 | location.reload(); 52 | } 53 | } 54 | 55 | private checkRoomExistenceAndShowError(errorMessage: string) { 56 | this.checkRoomExistence() 57 | .pipe( 58 | filter(response => !response), 59 | switchMap(() => this.promptService.alert('Ошибка', errorMessage)) 60 | ) 61 | .subscribe(() => { 62 | location.href = this.environment.config.baseUrl; 63 | }); 64 | } 65 | 66 | private checkRoomExistence(): Observable { 67 | const {roomId} = this.roomService; 68 | 69 | return this.apiService.getRoom(roomId) 70 | .pipe( 71 | map(() => true), 72 | catchError(() => [false]) 73 | ); 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /src/common/helpers/BinaryHeap.ts: -------------------------------------------------------------------------------- 1 | 2 | export class BinaryHeap { 3 | 4 | private content = []; 5 | 6 | constructor(private scoreFunction) { 7 | } 8 | 9 | push(element: any) { 10 | this.content.push(element); 11 | return this.sinkDown(this.content.length - 1); 12 | } 13 | 14 | pop() { 15 | let result = this.content[0]; 16 | let end = this.content.pop(); 17 | if (this.content.length) { 18 | this.content[0] = end; 19 | this.bubbleUp(0); 20 | } 21 | return result; 22 | } 23 | 24 | remove(node: any) { 25 | let i = this.content.indexOf(node); 26 | let end = this.content.pop(); 27 | if (i !== (this.content.length - 1)) { 28 | this.content[i] = end; 29 | if (this.scoreFunction(end) < this.scoreFunction(node)) { 30 | return this.sinkDown(i); 31 | } else { 32 | return this.bubbleUp(i); 33 | } 34 | } 35 | } 36 | 37 | size(): number { 38 | return this.content.length; 39 | } 40 | 41 | rescoreElement(node: any) { 42 | this.sinkDown(this.content.indexOf(node)); 43 | } 44 | 45 | sinkDown(n) { 46 | let element = this.content[n]; 47 | while (n > 0) { 48 | let parentN = ((n + 1) >> 1) - 1; 49 | let parent = this.content[parentN]; 50 | 51 | if (this.scoreFunction(element) < this.scoreFunction(parent)) { 52 | this.content[parentN] = element; 53 | this.content[n] = parent; 54 | n = parentN; 55 | } else { 56 | break; 57 | } 58 | } 59 | } 60 | 61 | bubbleUp(n: number) { 62 | let { length } = this.content; 63 | let element = this.content[n]; 64 | let elemScore = this.scoreFunction(element); 65 | 66 | while (true) { 67 | let child1Score; 68 | let child2N = (n + 1) << 1; 69 | let child1N = child2N - 1; 70 | let swap = null; 71 | 72 | if (child1N < length) { 73 | let child1 = this.content[child1N]; 74 | child1Score = this.scoreFunction(child1); 75 | 76 | if (child1Score < elemScore) { 77 | swap = child1N; 78 | } 79 | } 80 | 81 | if (child2N < length) { 82 | let child2 = this.content[child2N]; 83 | let child2Score = this.scoreFunction(child2); 84 | if (child2Score < (swap === null ? elemScore : child1Score)) { 85 | swap = child2N; 86 | } 87 | } 88 | 89 | if (swap !== null) { 90 | this.content[n] = this.content[swap]; 91 | this.content[swap] = element; 92 | n = swap; 93 | } else { 94 | break; 95 | } 96 | } 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/common/documentation/FirstSteps.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const FirstSteps = () => ( 4 |
5 |
6 |
7 | 8 |
9 |
10 | У нас есть 2 юнита: 'CSS' и 'PWA' 11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 |
19 |
20 | У противника: 'CSS' и '$' 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 |

29 | * Это ID юнитов которые вам нужно использовать в коде 30 |

31 | 32 |
33 | 34 |
35 |

Если заглянуть в характеристики персонажей:

36 |
    37 |
  • Стрелковые атаки будут менее эффективны против стрелков ('$')
  • 38 |
  • Чтобы нанести больше урона стрелкам надо подойти ближе к цели
  • 39 |
  • У нас есть один пехотинец ('CSS') с ближней атакой
  • 40 |
41 |
42 | 43 |
44 |

Как написать более-менее эффективный скрипт:

45 |
47 | 48 |
49 |
    50 |
  • Нажать "Сгенерировать пример кода", чтобы сразу получить стартовый код для нашей армии
  • 51 |
  • Подвести 'PWA' ближе к противнику, чтобы наносить больше урона
  • 52 |
  • 53 | Ближнюю атаку направить на '$' а стрелковую на — 54 | 'CSS' 55 |
  • 56 |
57 |
58 | 59 |
60 | Код выполняется всего один раз, за один ход юниты не могут 61 | дойти до персоанажа или полностью убить его за один выстрел 62 | потому накидывайте по-больше действий на юнитов 63 |
64 | 65 |
66 |
67 | ) -------------------------------------------------------------------------------- /src/common/battle/BattleGameScreen.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {BattleState} from "./BattleState.model"; 3 | import * as Phaser from "phaser"; 4 | import {WaitingView} from "./views/WaitingView"; 5 | import {BattleView} from "./views/BattleView"; 6 | import {AttentionView} from "./views/AttentionView"; 7 | import {ResultsView} from "./views/ResultsView"; 8 | import {WinView} from "./views/WinView"; 9 | import {LostView} from "./views/LostView"; 10 | import {ConnectionClosedView} from "./views/ConnectionClosedView"; 11 | import {ISessionResult} from "./BattleSession"; 12 | import { Observable } from 'rxjs/internal/Observable'; 13 | import {BattleGame} from './BattleGame'; 14 | import {Inject} from '../InjectDectorator'; 15 | 16 | interface IComponentState { 17 | } 18 | 19 | interface IProps { 20 | setState$?: Observable; 21 | runCode$: Observable<[string, string]>; 22 | } 23 | 24 | export class BattleGameScreen extends Component { 25 | 26 | state: IComponentState = {}; 27 | stateParams: any = {}; 28 | currentState: BattleState; 29 | 30 | @Inject(BattleGame) private battleGame: BattleGame; 31 | private game: Phaser.Game; 32 | 33 | componentDidMount() { 34 | this.battleGame.register(this); 35 | 36 | this.props.runCode$.subscribe(([leftCode, rightCode]) => { 37 | this.runCode(leftCode, rightCode); 38 | }); 39 | 40 | this.props.setState$.subscribe((state: BattleState) => { 41 | this.setGameState(state); 42 | }); 43 | } 44 | 45 | shouldComponentUpdate(): boolean { 46 | return false; 47 | } 48 | 49 | render(props: IProps, state: IComponentState) { 50 | return ( 51 |
this.initGame(ref)} class="playerDisplay" /> 52 | ); 53 | } 54 | 55 | setGameState(newState: BattleState, stateParams: any = {}) { 56 | if (this.currentState === newState) { 57 | return; 58 | } 59 | 60 | this.game.scene.switch(BattleState.wait, newState); 61 | 62 | this.currentState = newState; 63 | this.stateParams = stateParams || {}; 64 | } 65 | 66 | runCode(leftCode: string, rightCode: string) { 67 | this.setState(BattleState.battle); 68 | 69 | const battleView = this.game.scene.getScene(BattleState.battle) as BattleView; 70 | 71 | battleView.runCode$.next([leftCode, rightCode]); 72 | } 73 | 74 | showResults(sessionResult: ISessionResult) { 75 | this.setGameState(BattleState.results, sessionResult); 76 | 77 | const resultsView = this.game.scene.getScene(BattleState.results) as ResultsView; 78 | 79 | resultsView.setResults(sessionResult); 80 | } 81 | 82 | private initGame(parent: HTMLElement) { 83 | const config = { 84 | type: Phaser.AUTO, 85 | width: 400, 86 | height: 275, 87 | parent, 88 | scene: [WaitingView, BattleView, AttentionView, ResultsView, WinView, LostView, ConnectionClosedView] 89 | }; 90 | 91 | this.game = new Phaser.Game(config); 92 | } 93 | } -------------------------------------------------------------------------------- /src/common/helpers/Astar.ts: -------------------------------------------------------------------------------- 1 | 2 | import {BinaryHeap} from "./BinaryHeap"; 3 | import {GraphNode} from "./GraphNode"; 4 | import {Graph} from "./Graph"; 5 | 6 | export interface IPathItem { 7 | x: number, 8 | y: number 9 | } 10 | 11 | let pathTo = function(node: GraphNode): IPathItem[] { 12 | let curr = node; 13 | const path: IPathItem[] = []; 14 | 15 | while (curr.parent) { 16 | path.unshift({x: curr.x, y: curr.y}); 17 | curr = curr.parent; 18 | } 19 | 20 | return path; 21 | }; 22 | 23 | export class Astar { 24 | 25 | heuristics = { 26 | manhattan(pos0, pos1): number { 27 | let d1 = Math.abs(pos1.x - pos0.x); 28 | let d2 = Math.abs(pos1.y - pos0.y); 29 | return d1 + d2; 30 | }, 31 | diagonal(pos0, pos1): number { 32 | let D = 1; 33 | let D2 = Math.sqrt(2); 34 | let d1 = Math.abs(pos1.x - pos0.x); 35 | let d2 = Math.abs(pos1.y - pos0.y); 36 | return (D * (d1 + d2)) + ((D2 - (2 * D)) * Math.min(d1, d2)); 37 | } 38 | }; 39 | 40 | // 41 | // graph: Graph 42 | // start: graph.grid.get(x, y) 43 | // end: graph.grid.get(x, y) 44 | // 45 | search(graph: Graph, start: GraphNode, end: GraphNode): IPathItem[] { 46 | graph.cleanDirty(); 47 | 48 | const heuristic = this.heuristics.diagonal; 49 | const openHeap = new BinaryHeap(node => node.f); 50 | const closedNodes = new WeakSet(); 51 | const visitedNodes = new WeakSet(); 52 | let pathEnd = null; 53 | 54 | start.h = heuristic(start, end); 55 | graph.markDirty(start); 56 | 57 | openHeap.push(start); 58 | 59 | while (openHeap.size() > 0) { 60 | let currentNode = openHeap.pop(); 61 | 62 | if (currentNode === end) { 63 | let path = pathTo(currentNode); 64 | if (pathEnd) { 65 | path.push(pathEnd); 66 | } 67 | return path; 68 | } 69 | 70 | closedNodes.add(currentNode); 71 | 72 | const neighbors = graph.neighbors(currentNode); 73 | 74 | for (let neighbor of neighbors) { 75 | if (closedNodes.has(neighbor) || neighbor.isWall()) { 76 | continue; 77 | } 78 | 79 | let gScore = currentNode.g + neighbor.getCost(currentNode); 80 | let beenVisited = visitedNodes.has(neighbor); 81 | 82 | if (!beenVisited || (gScore < neighbor.g)) { 83 | visitedNodes.add(neighbor); 84 | neighbor.parent = currentNode; 85 | neighbor.h = neighbor.h || heuristic(neighbor, end); 86 | neighbor.g = gScore; 87 | neighbor.f = neighbor.g + neighbor.h; 88 | graph.markDirty(neighbor); 89 | 90 | if (!beenVisited) { 91 | openHeap.push(neighbor); 92 | } else { 93 | openHeap.rescoreElement(neighbor); 94 | } 95 | } 96 | } 97 | } 98 | 99 | return []; 100 | } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /src/common/documentation/Documentation.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {AccordionSection} from './AccordionSection'; 3 | import {BasicFAQ} from './BasicFAQ'; 4 | import {filter} from 'rxjs/internal/operators'; 5 | import {fromEvent, merge, Observable, Subject} from 'rxjs/index'; 6 | import {BasicJS} from './BasicJS'; 7 | import {FirstSteps} from './FirstSteps'; 8 | import {UnitApi} from './UnitApi'; 9 | import {UsefulTips} from './UsefulTips'; 10 | import {HowCodeWorks} from './HowCodeWorks'; 11 | 12 | interface IComponentState { 13 | } 14 | 15 | interface IProps { 16 | open$: Observable; 17 | } 18 | 19 | export class Documentation extends Component { 20 | 21 | state: IComponentState = {}; 22 | 23 | onCloseClick$ = new Subject(); 24 | 25 | get container(): HTMLElement { 26 | return this.base; 27 | } 28 | 29 | set opened(value: boolean) { 30 | this.container.classList.toggle('opened', value); 31 | } 32 | 33 | get backdropClick$(): Observable { 34 | return fromEvent(this.container, 'click') 35 | .pipe(filter(event => event.target === this.container)) 36 | } 37 | 38 | get onEscape$(): Observable { 39 | return fromEvent(window, 'keydown') 40 | .pipe(filter(event => event.keyCode === 27)) 41 | } 42 | 43 | componentDidMount() { 44 | this.opened = true; 45 | 46 | this.props.open$ 47 | .subscribe(() => { 48 | this.opened = true; 49 | }); 50 | 51 | merge( 52 | this.onEscape$, 53 | this.backdropClick$, 54 | this.onCloseClick$ 55 | ) 56 | .subscribe(() => { 57 | this.opened = false; 58 | }); 59 | } 60 | 61 | render(props: IProps, state: IComponentState) { 62 | return ( 63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 88 |
89 |
90 | ); 91 | } 92 | 93 | } -------------------------------------------------------------------------------- /src/admin/PromptService.tsx: -------------------------------------------------------------------------------- 1 | import {Observable, Subject} from 'rxjs'; 2 | import {render, h} from 'preact'; 3 | import {PromptModal} from './PromptModal'; 4 | import {Inject} from "../common/InjectDectorator"; 5 | import {ApiService} from "../common/ApiService"; 6 | import {switchMap} from "rxjs/operators"; 7 | 8 | export class PromptService { 9 | 10 | @Inject(ApiService) private apiService: ApiService; 11 | 12 | prompt(title: string): Observable<{title: string}> { 13 | const onSubmit$ = new Subject<{title: string}>(); 14 | const modalContainer = document.querySelector('.modals'); 15 | const template = (); 16 | 17 | modalContainer.innerHTML = ''; 18 | 19 | render(( 20 |
21 | 22 |
23 | ), modalContainer); 24 | 25 | return onSubmit$.asObservable(); 26 | } 27 | 28 | alert(title: string, text: string): Observable<{}> { 29 | const onSubmit$ = new Subject<{title: string}>(); 30 | const modalContainer = document.querySelector('.modals'); 31 | const template = (
{text}
); 32 | 33 | modalContainer.innerHTML = ''; 34 | 35 | render(( 36 |
37 | 38 |
39 | ), modalContainer); 40 | 41 | return onSubmit$.asObservable(); 42 | } 43 | 44 | goToMaster(): Observable<{}> { 45 | const onSubmit$ = new Subject<{title: string}>(); 46 | const modalContainer = document.querySelector('.modals'); 47 | const title = 'Внимание на главный экран!'; 48 | // const template = 'Нажмите Ок, чтобы перейти к бою'; 49 | const template = 'Через 3 секунды начнется бой!'; 50 | 51 | modalContainer.innerHTML = ''; 52 | 53 | render(( 54 |
55 | 56 |
57 | ), modalContainer); 58 | 59 | return onSubmit$.asObservable(); 60 | } 61 | 62 | loginModal(): Observable { 63 | const onSubmit$ = new Subject<{username: string, password: string}>(); 64 | const modalContainer = document.querySelector('.modals'); 65 | const title = 'Требуется авторизация'; 66 | const template = ( 67 | 77 | ); 78 | 79 | modalContainer.innerHTML = ''; 80 | 81 | render(( 82 |
83 | 84 |
85 | ), modalContainer); 86 | 87 | return onSubmit$.asObservable() 88 | .pipe(switchMap(({username, password}) => this.apiService.login(username, password))) 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /src/admin/RoomListComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Inject} from "../common/InjectDectorator"; 2 | import {ApiService} from "../common/ApiService"; 3 | import {BehaviorSubject, merge, Subject} from "rxjs"; 4 | import "./RoomItemComponent"; 5 | import {RoomItemComponent} from "./RoomItemComponent"; 6 | import {RoomModel} from "../../server/models/RoomModel"; 7 | import {Component, h} from "preact"; 8 | import {debounceTime, takeUntil} from 'rxjs/internal/operators'; 9 | import {WebsocketConnection} from '../common/WebsocketConnection'; 10 | import {PromptService} from './PromptService'; 11 | import {BattleState} from '../common/battle/BattleState.model'; 12 | 13 | interface IComponentState { 14 | items: {[key: string]: RoomModel} 15 | } 16 | 17 | interface IProps { 18 | isAdmin: boolean; 19 | adminToken?: string; 20 | } 21 | 22 | interface IRoomItem { 23 | id: string; 24 | room: RoomModel; 25 | } 26 | 27 | export class RoomListComponent extends Component { 28 | update$ = new BehaviorSubject([]); 29 | 30 | state = { 31 | items: {} 32 | }; 33 | 34 | @Inject(ApiService) private apiService: ApiService; 35 | @Inject(PromptService) private promptService: PromptService; 36 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 37 | 38 | private unmount$ = new Subject(); 39 | 40 | componentDidMount() { 41 | merge( 42 | this.update$, 43 | this.connection.onRoomsChanged$ 44 | .pipe(debounceTime(100)) 45 | ) 46 | .pipe(takeUntil(this.unmount$)) 47 | .subscribe(() => { 48 | this.updateRooms(); 49 | }); 50 | } 51 | 52 | render(props: IProps, state: IComponentState) { 53 | const rooms = Object.keys(state.items) 54 | .map(id => ({id, room: state.items[id]})) 55 | .sort((a, b) => 56 | a.room.state.createTime < b.room.state.createTime ? 1 : -1 57 | ); 58 | 59 | const current = rooms.filter(({room}) => room.state.mode !== BattleState.results); 60 | const past = rooms.filter(({room}) => room.state.mode === BattleState.results); 61 | 62 | return ( 63 |
64 | {this.renderRoomsWithHeader(current, 'Текущие бои:')} 65 | 66 | {this.renderRoomsWithHeader(past, 'Прошедшие бои:')} 67 |
68 | ) 69 | } 70 | 71 | renderRoomsWithHeader(items: IRoomItem[], title: string) { 72 | if (items.length === 0) { 73 | return; 74 | } 75 | 76 | return ( 77 |
78 |

{title}

79 |
80 | {this.renderRoomsList(items)} 81 |
82 |
83 | ) 84 | } 85 | 86 | renderRoomsList(items: IRoomItem[]) { 87 | const {isAdmin, adminToken} = this.props; 88 | 89 | return items.map(({id, room}) => { 90 | return () 91 | }) 92 | } 93 | 94 | componentWillUnmount() { 95 | this.unmount$.next(); 96 | } 97 | 98 | private updateRooms() { 99 | this.apiService.getAllRooms(this.props.isAdmin) 100 | .subscribe(items => { 101 | this.setState({items}); 102 | }); 103 | } 104 | } -------------------------------------------------------------------------------- /src/common/helpers/Graph.ts: -------------------------------------------------------------------------------- 1 | 2 | import {Grid} from "./Grid"; 3 | import {GraphNode} from "./GraphNode"; 4 | 5 | export class Graph { 6 | private dirtyNodes: Set; 7 | grid: Grid; 8 | 9 | constructor(width: number, height: number) { 10 | this.dirtyNodes = new Set(); 11 | this.grid = new Grid(width); 12 | 13 | let x = width; 14 | while (x--) { 15 | let y = height; 16 | while (y--) { 17 | let node = new GraphNode(x, y, 1); 18 | this.cleanNode(node); 19 | this.grid.set(x, y, node); 20 | } 21 | } 22 | 23 | this.dirtyNodes.clear(); 24 | } 25 | 26 | cleanNode(node: GraphNode) { 27 | node.f = 0; 28 | node.g = 0; 29 | node.h = 0; 30 | node.parent = null; 31 | } 32 | 33 | cleanDirty() { 34 | this.dirtyNodes.forEach(this.cleanNode); 35 | this.dirtyNodes.clear(); 36 | } 37 | 38 | markDirty(node: GraphNode) { 39 | this.dirtyNodes.add(node); 40 | } 41 | 42 | clearWeight() { 43 | this.grid.forEach(item => { item.weight = 0 }); 44 | } 45 | 46 | setWeight(x: number, y: number, weight: number) { 47 | const node = this.grid.get(x, y); 48 | 49 | node.weight = weight; 50 | } 51 | 52 | getWeight(x: number, y: number): number { 53 | const node = this.grid.get(x, y); 54 | 55 | return node.weight; 56 | } 57 | 58 | protected getWest(x: number, y: number): GraphNode { 59 | return this.grid.get(x - 1, y); 60 | } 61 | 62 | protected getEast(x: number, y: number): GraphNode { 63 | return this.grid.get(x + 1, y); 64 | } 65 | 66 | protected getSouth(x: number, y: number): GraphNode { 67 | return this.grid.get(x, y - 1); 68 | } 69 | 70 | protected getNorth(x: number, y: number): GraphNode { 71 | return this.grid.get(x, y + 1); 72 | } 73 | 74 | protected getSouthWest(x: number, y: number): GraphNode { 75 | return this.grid.get(x - 1, y - 1); 76 | } 77 | 78 | protected getSouthEast(x: number, y: number): GraphNode { 79 | return this.grid.get(x + 1, y - 1); 80 | } 81 | 82 | protected getNorthWest(x: number, y: number): GraphNode { 83 | return this.grid.get(x - 1, y + 1); 84 | } 85 | 86 | protected getNorthEast(x: number, y: number): GraphNode { 87 | return this.grid.get(x + 1, y + 1); 88 | } 89 | 90 | neighbors({x, y}: GraphNode): GraphNode[] { 91 | let cell; 92 | const ret = []; 93 | 94 | if (cell = this.getWest(x, y)) { 95 | ret.push(cell); 96 | } 97 | 98 | if (cell = this.getEast(x, y)) { 99 | ret.push(cell); 100 | } 101 | 102 | if (cell = this.getSouth(x, y)) { 103 | ret.push(cell); 104 | } 105 | 106 | if (cell = this.getNorth(x, y)) { 107 | ret.push(cell); 108 | } 109 | 110 | if (cell = this.getSouthWest(x, y)) { 111 | ret.push(cell); 112 | } 113 | 114 | if (cell = this.getSouthEast(x, y)) { 115 | ret.push(cell); 116 | } 117 | 118 | if (cell = this.getNorthWest(x, y)) { 119 | ret.push(cell); 120 | } 121 | 122 | if (cell = this.getNorthEast(x, y)) { 123 | ret.push(cell); 124 | } 125 | 126 | return ret; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/admin/AdminApp.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import {RoomListComponent} from './RoomListComponent'; 3 | import {WebsocketConnection} from '../common/WebsocketConnection'; 4 | import {Inject} from '../common/InjectDectorator'; 5 | import {Observable} from 'rxjs/index'; 6 | import {catchError, filter, first, map, share, switchMap} from 'rxjs/internal/operators'; 7 | import {ApiService} from '../common/ApiService'; 8 | import {PromptService} from './PromptService'; 9 | 10 | export class AdminApp { 11 | 12 | @Inject(ApiService) private apiService: ApiService; 13 | @Inject(PromptService) private promptService: PromptService; 14 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 15 | 16 | get onAdminToken$(): Observable { 17 | return this.connection.onMessage$ 18 | .pipe( 19 | filter(message => message.type === 'adminToken'), 20 | map(message => message.data) 21 | ) 22 | } 23 | 24 | get isAdmin$(): Observable { 25 | return this.apiService.getAuthData() 26 | .pipe( 27 | map(user => user.isAdmin), 28 | catchError(() => [false]) 29 | ); 30 | } 31 | 32 | constructor() { 33 | this.tryLoginAsAdmin(); 34 | } 35 | 36 | private saveRoomsState(token: string) { 37 | this.apiService.saveRoomsState(token).subscribe(); 38 | } 39 | 40 | private initScreen() { 41 | this.connection.registerAsAdmin(); 42 | 43 | this.onAdminToken$.pipe(first()).subscribe(adminToken => { 44 | render(( 45 |
46 |

Администрирование комнат

47 |
48 |
49 | 50 | 52 | 53 |
54 | 55 |
56 | 57 |
58 |
59 |
60 | ), document.querySelector('.admin')); 61 | }); 62 | } 63 | 64 | private tryLoginAsAdmin() { 65 | const request$ = this.isAdmin$.pipe(share()); 66 | 67 | request$ 68 | .pipe(filter(isAdmin => isAdmin)) 69 | .subscribe(() => { 70 | this.initScreen(); 71 | }); 72 | 73 | request$ 74 | .pipe( 75 | filter(isAdmin => !isAdmin), 76 | switchMap(() => this.promptService.loginModal()), 77 | catchError(() => [false]) 78 | ) 79 | .subscribe(() => { 80 | this.tryLoginAsAdmin(); 81 | }); 82 | } 83 | 84 | private logout() { 85 | this.apiService.logout().subscribe(() => { 86 | location.reload(); 87 | }); 88 | } 89 | 90 | private createRoom(adminToken: string) { 91 | const id = Math.random().toString(36).substring(3); 92 | 93 | this.promptService.prompt('Введите название комнаты') 94 | .pipe(switchMap(({title}) => this.apiService.createRoom(id, title, adminToken))) 95 | .subscribe(); 96 | } 97 | } -------------------------------------------------------------------------------- /server/clients/Client.ts: -------------------------------------------------------------------------------- 1 | import {IMessage} from '../../src/common/WebsocketConnection'; 2 | import {IState} from '../../src/common/state.model'; 3 | import {mergeDeep} from '../../src/common/helpers/mergeDeep'; 4 | import * as ws from 'ws'; 5 | import {fromEvent, Observable, Subject} from "rxjs"; 6 | import {catchError, filter, map} from "rxjs/operators"; 7 | 8 | export abstract class Client { 9 | 10 | registered$ = new Subject(); 11 | 12 | abstract onMessage$: Observable; 13 | 14 | protected maxConnections = 1; 15 | 16 | private connectionsPool = new Set(); 17 | private messagesToSend: IMessage[] = []; 18 | private clientState: Partial = {}; 19 | private mainConnection: ws; 20 | private unsafeMessage$ = new Subject(); 21 | 22 | setConnection(connection: ws) { 23 | this.connectionsPool.add(connection); 24 | 25 | if (this.connectionsPool.size === 1) { 26 | this.mainConnection = connection; 27 | } 28 | 29 | this.registered$.next(); 30 | 31 | fromEvent(connection, 'message') 32 | .pipe( 33 | filter(message => message.type === 'message'), 34 | map(message => JSON.parse(message.data)), 35 | catchError(error => { 36 | console.error(error); 37 | 38 | return []; 39 | }) 40 | ) 41 | .subscribe(message => { 42 | this.unsafeMessage$.next(message); 43 | }); 44 | 45 | this.send({ 46 | type: 'setState', 47 | data: this.clientState 48 | }); 49 | 50 | this.messagesToSend.forEach(message => { 51 | this.send(message); 52 | }); 53 | 54 | this.messagesToSend = []; 55 | } 56 | 57 | setState(newState: Partial) { 58 | this.clientState = mergeDeep(this.clientState, newState); 59 | 60 | if (this.isEmpty()) { 61 | return; 62 | } 63 | 64 | this.send({ 65 | type: 'setState', 66 | data: this.clientState 67 | }) 68 | } 69 | 70 | dispatchNewSession() { 71 | this.messagesToSend = []; 72 | this.clientState = {}; 73 | 74 | this.send({ 75 | type: 'newSession', 76 | data: null 77 | }); 78 | } 79 | 80 | canConnect(connection: ws): boolean { 81 | return this.connectionsPool.size < this.maxConnections 82 | && !this.connectionsPool.has(connection); 83 | } 84 | 85 | disconnect(connection: ws) { 86 | this.connectionsPool.delete(connection); 87 | 88 | if (this.isMain(connection) && this.connectionsPool.size > 0) { 89 | const [firstConnection] = [...this.connectionsPool.values()]; 90 | 91 | this.mainConnection = firstConnection; 92 | } 93 | } 94 | 95 | isMain(connection: ws): boolean { 96 | return connection === this.mainConnection; 97 | } 98 | 99 | protected onUnsafeMessage$(type: string): Observable { 100 | return this.unsafeMessage$ 101 | .pipe(filter(message => message.type === type)) 102 | } 103 | 104 | protected send(data: IMessage) { 105 | if (this.isEmpty()) { 106 | this.messagesToSend.push(data); 107 | 108 | return; 109 | } 110 | 111 | this.connectionsPool.forEach(connection => { 112 | if (connection.readyState === ws.OPEN) { 113 | connection.send(JSON.stringify(data)); 114 | } 115 | }); 116 | } 117 | 118 | protected isEmpty(): boolean { 119 | return this.connectionsPool.size === 0; 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /src/admin/LeadersGridComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Inject} from '../common/InjectDectorator'; 2 | import {ApiService} from '../common/ApiService'; 3 | import {IArmyState, IState} from '../common/state.model'; 4 | import {Component, h} from 'preact'; 5 | import {Wreath} from './Wreath'; 6 | import {EMPTY_ARMY} from "../common/client/EMPTY_ARMY"; 7 | 8 | interface IGridState { 9 | items: IState[] 10 | } 11 | 12 | export class LeadersGridComponent extends Component { 13 | 14 | @Inject(ApiService) private apiService: ApiService; 15 | 16 | state = { 17 | items: [] 18 | }; 19 | 20 | constructor() { 21 | super(); 22 | 23 | this.updateLeaders(); 24 | } 25 | 26 | render(props, state: Partial) { 27 | const items = state.items.sort((a, b) => { 28 | const first = Math.max(a.damage.left, a.damage.right); 29 | const second = Math.max(b.damage.left, b.damage.right); 30 | 31 | if (first > second) { 32 | return -1; 33 | } 34 | 35 | return 1; 36 | }); 37 | 38 | return ( 39 |
40 | 41 | 42 | {items.map((item, index) => ( 43 | 44 | 45 | 62 | 77 | 78 | 79 | ))} 80 | 81 |
{this.renderUnits(item.left.army)} 46 | 47 |
48 |
49 | 50 |
51 |
52 |
53 |
{item.left.name}
54 |
55 |
56 |
{item.damage.left}
57 |
58 |
59 |
60 | 61 |
63 |
64 |
65 |
66 |
{item.right.name}
67 |
68 |
69 |
{item.damage.right}
70 |
71 |
72 |
73 | 74 |
75 |
76 |
{this.renderUnits(item.right.army)}
82 |
83 | ) 84 | } 85 | 86 | private updateLeaders() { 87 | this.apiService.getLeaderBoard() 88 | .subscribe(items => { 89 | this.setState({items}); 90 | }); 91 | } 92 | 93 | private renderUnits(army: IArmyState = EMPTY_ARMY) { 94 | return Object.keys(army).map(i => ( 95 |
96 | )); 97 | } 98 | 99 | } -------------------------------------------------------------------------------- /src/master/MasterScreen.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {Inject, setInject} from '../common/InjectDectorator'; 3 | import {WebsocketConnection} from '../common/WebsocketConnection'; 4 | import {BattleSide} from '../common/battle/BattleSide'; 5 | import {Subject, timer} from 'rxjs/index'; 6 | import {ClientDisplay} from '../common/client/ClientDisplay'; 7 | import {RoomTimer} from '../common/roomTimer/RoomTimer'; 8 | import {BattleGameScreen} from '../common/battle/BattleGameScreen'; 9 | import {CodeDisplay} from './CodeDisplay'; 10 | import {IState} from '../common/state.model'; 11 | import {first, map, switchMap, tap} from 'rxjs/internal/operators'; 12 | import {LeftArmy} from '../left/LeftArmy'; 13 | import {BattleState} from '../common/battle/BattleState.model'; 14 | import {Maybe} from '../common/helpers/Maybe'; 15 | import {RightArmy} from '../right/RightArmy'; 16 | 17 | interface IComponentState { 18 | state: IState; 19 | } 20 | 21 | interface IProps { 22 | state: IState; 23 | } 24 | 25 | export class MasterScreen extends Component { 26 | 27 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 28 | 29 | state: IComponentState = { 30 | state: this.props.state 31 | }; 32 | 33 | private stateIsReady$ = new Subject(); 34 | private setState$ = new Subject(); 35 | private runCode$ = new Subject<[string, string]>(); 36 | 37 | componentDidMount() { 38 | this.connection.onState$() 39 | .subscribe(state => { 40 | this.setState({state}); 41 | }); 42 | 43 | this.stateIsReady$ 44 | .pipe( 45 | first(), 46 | switchMap(state => 47 | timer(2000).pipe(map(() => state)) 48 | ), 49 | tap(state => { 50 | setInject(LeftArmy, state.left.army); 51 | setInject(RightArmy, state.right.army); 52 | 53 | this.setState$.next(BattleState.battle); 54 | }), 55 | switchMap(state => 56 | timer(1000).pipe(map(() => state)) 57 | ), 58 | ) 59 | .subscribe(state => { 60 | const leftCode = Maybe(state).pluck('left.editor.code').getOrElse(''); 61 | const rightCode = Maybe(state).pluck('right.editor.code').getOrElse(''); 62 | 63 | this.runCode$.next([leftCode, rightCode]); 64 | }); 65 | 66 | this.connection.onClose$.subscribe(() => { 67 | this.setState$.next(BattleState.connectionClosed); 68 | }); 69 | 70 | this.onRoomStateChange(this.state.state); 71 | } 72 | 73 | componentWillUpdate(props: IProps, {state}: IComponentState) { 74 | this.onRoomStateChange(state); 75 | } 76 | 77 | render(props: IProps, {state}: IComponentState) { 78 | return ( 79 |
80 |
81 | 82 | 83 | 84 |
85 | 86 | 87 | 88 | 89 |
90 | ); 91 | } 92 | 93 | private onRoomStateChange(state: IState) { 94 | if (state.mode) { 95 | this.base.className = `master ${state.mode}`; 96 | } 97 | 98 | if (state.mode === BattleState.ready || state.mode === BattleState.results) { 99 | this.stateIsReady$.next(state); 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /src/common/battle/BattleFieldDrawer.ts: -------------------------------------------------------------------------------- 1 | import {color} from "../helpers/color"; 2 | 3 | const HEXAGON_ANGLE = 0.523598776; 4 | const FIELD_WIDTH = 12; 5 | const FIELD_HEIGHT = 9; 6 | const SIDE_LENGTH = 18; 7 | 8 | const hexHeight = Math.round(Math.sin(HEXAGON_ANGLE) * SIDE_LENGTH); 9 | const hexRadius = Math.round(Math.cos(HEXAGON_ANGLE) * SIDE_LENGTH); 10 | const hexRectangleHeight = Math.round(SIDE_LENGTH + (2 * hexHeight)); 11 | const hexRectangleWidth = Math.round(2 * hexRadius); 12 | 13 | export class BattleFieldDrawer { 14 | 15 | width = (FIELD_WIDTH * hexRectangleWidth) + hexRectangleWidth / 2; 16 | height = (FIELD_HEIGHT * (SIDE_LENGTH + hexHeight)) + (SIDE_LENGTH + hexHeight) / 2; 17 | 18 | private hexagonGraphic: HTMLCanvasElement; 19 | 20 | draw(ctx: CanvasRenderingContext2D) { 21 | this.hexagonGraphic = this.prepareHexagon(); 22 | const boardGraphic = this.prepareBoard(); 23 | 24 | ctx.globalAlpha = 0.5; 25 | 26 | ctx.drawImage(boardGraphic, 0, 0); 27 | } 28 | 29 | getHexagonLeft(x: number, y: number): number { 30 | const rowMargin = ((y + 1) % 2) * hexRadius; 31 | 32 | return (x * hexRectangleWidth) + rowMargin; 33 | } 34 | 35 | getHexagonTop(x: number, y: number): number { 36 | return y * (SIDE_LENGTH + hexHeight); 37 | } 38 | 39 | forEachBoard(callback: Function) { 40 | let y = FIELD_HEIGHT; 41 | 42 | while (y--) { 43 | let x = FIELD_WIDTH; 44 | 45 | while (x--) { 46 | const left = this.getHexagonLeft(x, y); 47 | const top = this.getHexagonTop(x, y); 48 | 49 | callback(left, top, x, y); 50 | } 51 | } 52 | } 53 | 54 | private makeCtx(width: number, height: number): CanvasRenderingContext2D { 55 | const canvas = document.createElement('canvas'); 56 | 57 | Object.assign(canvas, {width, height}); 58 | 59 | return canvas.getContext('2d'); 60 | } 61 | 62 | private makeHexagon(): CanvasRenderingContext2D { 63 | let x = 0; 64 | let y = 0; 65 | let ctx = this.makeCtx(hexRectangleWidth, hexRectangleHeight); 66 | ctx.beginPath(); 67 | ctx.moveTo(x + hexRadius, y); 68 | ctx.lineTo(x + hexRectangleWidth, y + hexHeight); 69 | ctx.lineTo(x + hexRectangleWidth, y + hexHeight + SIDE_LENGTH); 70 | ctx.lineTo(x + hexRadius, y + hexRectangleHeight); 71 | ctx.lineTo(x, y + SIDE_LENGTH + hexHeight); 72 | ctx.lineTo(x, y + hexHeight); 73 | ctx.closePath(); 74 | 75 | return ctx; 76 | } 77 | 78 | private prepareHexagon(): HTMLCanvasElement { 79 | let ctx = this.makeHexagon(); 80 | ctx.strokeStyle = 'rgba(118, 255, 5, 0.6)'; 81 | ctx.stroke(); 82 | 83 | return ctx.canvas; 84 | } 85 | 86 | private prepareBoard(): HTMLCanvasElement { 87 | const ctx = this.makeCtx(this.width, this.height); 88 | const factor = 30; 89 | const baseColor = color('#2b5720'); 90 | const baseR = ((baseColor >> 16) - (factor >> 1)) & 255; 91 | const baseG = ((baseColor >> 8) - (factor >> 1)) & 255; 92 | const baseB = ((baseColor >> 0) - (factor >> 1)) & 255; 93 | 94 | for (let y = 0; y < 300; y += 2) { 95 | for (let x = 0; x < 400; x += 2) { 96 | let r = Math.max(0, baseR + Math.floor(Math.random() * factor)) & 255; 97 | let g = Math.max(0, baseG + Math.floor(Math.random() * factor)) & 255; 98 | let b = Math.max(0, baseB + Math.floor(Math.random() * factor)) & 255; 99 | 100 | const hexColor = ((r << 16) | (g << 8) | (b << 0)).toString(16).padStart(6, '0'); 101 | 102 | ctx.fillStyle = `#${hexColor}`; 103 | ctx.fillRect(x, y, 2, 2); 104 | } 105 | } 106 | 107 | this.forEachBoard((left, top) => { 108 | ctx.drawImage(this.hexagonGraphic, left, top); 109 | }); 110 | 111 | return ctx.canvas; 112 | } 113 | } -------------------------------------------------------------------------------- /src/common/WebsocketConnection.ts: -------------------------------------------------------------------------------- 1 | import {Observable, Subject} from 'rxjs/index'; 2 | import {ISessionResult} from "./battle/BattleSession"; 3 | import {Inject} from './InjectDectorator'; 4 | import {Environment} from './Environment'; 5 | import {IState} from './state.model'; 6 | import {filter, pluck} from 'rxjs/internal/operators'; 7 | 8 | export interface IMessage { 9 | type: string; 10 | data?: any; 11 | state?: any; 12 | roomId?: string; 13 | } 14 | 15 | export class WebsocketConnection { 16 | 17 | @Inject(Environment) private environment: Environment; 18 | 19 | onMessage$ = new Subject(); 20 | onClose$ = new Subject(); 21 | isMaster = false; 22 | 23 | get onRoomsChanged$() { 24 | return this.onMessage$ 25 | .pipe( 26 | filter(message => message.type === 'roomsChanged') 27 | ) 28 | } 29 | 30 | private connection: WebSocket; 31 | private readyPromise: Promise; 32 | 33 | constructor() { 34 | this.connection = new WebSocket(this.environment.config.websocket); 35 | 36 | this.readyPromise = new Promise((resolve, reject) => { 37 | this.connection.onopen = () => { 38 | resolve(); 39 | }; 40 | 41 | this.connection.onerror = (error) => { 42 | reject(); 43 | this.onClose$.next(); 44 | }; 45 | }); 46 | 47 | this.connection.onclose = () => { 48 | this.onClose$.next(); 49 | }; 50 | 51 | this.connection.onmessage = (message) => { 52 | // try to decode json (I assume that each message 53 | // from server is json) 54 | try { 55 | this.onMessage$.next(JSON.parse(message.data)); 56 | } catch (e) { 57 | console.log('This doesn\'t look like a valid JSON: ', 58 | message.data); 59 | return; 60 | } 61 | // handle incoming message 62 | }; 63 | } 64 | 65 | onState$(...path: string[]): Observable { 66 | return this.onMessage$ 67 | .pipe( 68 | filter(message => message.type === 'setState'), 69 | pluck('data', ...path), 70 | filter(data => data !== null && data !== undefined) 71 | ) 72 | } 73 | 74 | registerAsGuest() { 75 | this.send(JSON.stringify({ 76 | type: 'registerGuest' 77 | })); 78 | } 79 | 80 | registerAsAdmin() { 81 | this.send(JSON.stringify({ 82 | type: 'registerAdmin' 83 | })); 84 | } 85 | 86 | registerAsMaster(roomId: string) { 87 | this.send(JSON.stringify({ 88 | type: 'registerMaster', 89 | roomId 90 | })); 91 | 92 | this.isMaster = true; 93 | } 94 | 95 | registerAsLeftPlayer(roomId: string) { 96 | this.send(JSON.stringify({ 97 | type: 'registerLeftPlayer', 98 | roomId 99 | })); 100 | } 101 | 102 | registerAsRightPlayer(roomId: string) { 103 | this.send(JSON.stringify({ 104 | type: 'registerRightPlayer', 105 | roomId 106 | })); 107 | } 108 | 109 | sendWinner(sessionResult: ISessionResult, roomId: string) { 110 | this.send(JSON.stringify({ 111 | type: 'sendWinner', 112 | sessionResult, 113 | roomId 114 | })); 115 | } 116 | 117 | sendNewSession(roomId: string) { 118 | this.send(JSON.stringify({ 119 | type: 'newSession', 120 | roomId 121 | })); 122 | } 123 | 124 | sendState(state: Partial, roomId: string) { 125 | this.send(JSON.stringify({ 126 | type: 'state', 127 | state, 128 | roomId 129 | })); 130 | } 131 | 132 | private send(message: string) { 133 | this.readyPromise.then(() => { 134 | this.connection.send(message); 135 | }); 136 | } 137 | } -------------------------------------------------------------------------------- /server/SocketMiddleware.ts: -------------------------------------------------------------------------------- 1 | import * as ws from 'ws'; 2 | import {catchError, filter, first, map, tap} from 'rxjs/operators'; 3 | import {Observable, fromEvent, throwError, merge} from 'rxjs'; 4 | import {Inject} from '../src/common/InjectDectorator'; 5 | import {LeaderBoard} from './storages/LeaderBoard'; 6 | import {RoomStorage} from "./storages/RoomStorage"; 7 | import {ConnectionsStorage} from './storages/ConnectionsStorage'; 8 | 9 | export interface IClientRegisterMessage { 10 | type: string; 11 | roomId: string; 12 | } 13 | 14 | export class SocketMiddleware { 15 | 16 | @Inject(LeaderBoard) private leaderBoard: LeaderBoard; 17 | @Inject(RoomStorage) private roomStorage: RoomStorage; 18 | @Inject(ConnectionsStorage) private guestConnectionsStorage: ConnectionsStorage; 19 | 20 | get onRegisterMessage$(): Observable { 21 | return fromEvent(this.connection, 'message') 22 | .pipe( 23 | filter(message => message.type === 'message'), 24 | first(), 25 | map(message => JSON.parse(message.data)), 26 | filter(message => message.type.startsWith('register')) 27 | ) 28 | } 29 | 30 | get onClose$(): Observable { 31 | return merge(fromEvent(this.connection, 'close'), fromEvent(this.connection, 'error')); 32 | } 33 | 34 | get onGuestRegister$(): Observable { 35 | return merge( 36 | this.onRegister$('registerGuest'), 37 | this.onRegister$('registerAdmin') 38 | ) 39 | .pipe( 40 | tap(message => { 41 | this.tryRegisterGuestConnection(message, this.connection); 42 | }) 43 | ) 44 | } 45 | 46 | get onRoomMemberRegister$(): Observable { 47 | return merge( 48 | this.onRegister$('registerMaster'), 49 | this.onRegister$('registerLeftPlayer'), 50 | this.onRegister$('registerRightPlayer') 51 | ) 52 | .pipe( 53 | tap(message => { 54 | const {roomId} = message; 55 | const room = this.roomStorage.get(roomId); 56 | 57 | if (!room) { 58 | this.connection.close(); 59 | 60 | return throwError(`room id ${roomId} is not found`); 61 | } 62 | 63 | this.roomStorage.addConnection(this.connection, room); 64 | 65 | room.tryRegisterConnection(message, this.connection); 66 | }) 67 | ) 68 | } 69 | 70 | constructor(private connection: ws) { 71 | 72 | this.onClose$.subscribe(() => { 73 | const room = this.roomStorage.getRoomByConnection(this.connection); 74 | 75 | if (room) { 76 | room.onConnectionLost(this.connection); 77 | this.guestConnectionsStorage.dispatchRoomsChanged(); 78 | } 79 | }); 80 | 81 | merge( 82 | this.onGuestRegister$, 83 | this.onRoomMemberRegister$ 84 | ) 85 | .pipe( 86 | catchError(error => { 87 | console.log(error); 88 | 89 | return []; 90 | }) 91 | ) 92 | .subscribe(); 93 | } 94 | 95 | private onRegister$(type: string): Observable { 96 | return this.onRegisterMessage$ 97 | .pipe( 98 | filter(message => message.type === type) 99 | ) 100 | } 101 | 102 | private tryRegisterGuestConnection(data: IClientRegisterMessage, connection: ws) { 103 | if (!this.guestConnectionsStorage.isRegistered(connection)) { 104 | this.guestConnectionsStorage.registerConnection(data, connection); 105 | 106 | if (!this.guestConnectionsStorage.isRegistered(connection)) { 107 | connection.close(); 108 | 109 | return; 110 | } 111 | 112 | return; 113 | } 114 | } 115 | 116 | } -------------------------------------------------------------------------------- /src/common/ApiService.ts: -------------------------------------------------------------------------------- 1 | import {Observable, of, throwError} from 'rxjs/index'; 2 | import {fromPromise} from 'rxjs/internal/observable/fromPromise'; 3 | import {pluck, share, switchMap} from 'rxjs/internal/operators'; 4 | import {IState} from './state.model'; 5 | import {Inject} from './InjectDectorator'; 6 | import {Environment} from './Environment'; 7 | import {RoomModel} from "../../server/models/RoomModel"; 8 | import {IApiFullResponse} from '../../server/models/IApiFullResponse.model'; 9 | 10 | interface IUser { 11 | isAdmin: boolean; 12 | name: string; 13 | } 14 | 15 | export class ApiService { 16 | 17 | @Inject(Environment) private environment: Environment; 18 | 19 | getAuthData(): Observable { 20 | return this.get('/authData').pipe(pluck('result')); 21 | } 22 | 23 | getLeaderBoard(): Observable { 24 | return this.get('/leaderboard').pipe(pluck('result')); 25 | } 26 | 27 | createRoom(id: string, title: string, token: string): Observable { 28 | return this.post(`/rooms/${id}`, {title, token}).pipe(pluck('result')); 29 | } 30 | 31 | getRoom(name: string): Observable { 32 | return this.get(`/rooms/${name}`).pipe(pluck('result')); 33 | } 34 | 35 | deleteRoom(name: string, token: string): Observable { 36 | return this.delete(`/rooms/${name}?token=${token}`).pipe(pluck('result')); 37 | } 38 | 39 | getAllRooms(isAll = false): Observable<{[key: string]: RoomModel}> { 40 | return this.get<{[key: string]: RoomModel}>(`/rooms${isAll ? '?isAll=1' : ''}`).pipe(pluck('result')); 41 | } 42 | 43 | reloadRoomSession(id: string, token: string): Observable { 44 | return this.post(`/rooms/${id}/reload`, {token}).pipe(pluck('result')); 45 | } 46 | 47 | login(username: string, password: string): Observable { 48 | return this.post(`/login`, {username, password}).pipe(pluck('result')); 49 | } 50 | 51 | logout(): Observable { 52 | return this.post(`/logout`, {}).pipe(pluck('result')); 53 | } 54 | 55 | saveRoomsState(token: string): Observable { 56 | return this.post(`/saveRoomState`, {token}).pipe(pluck('result')); 57 | } 58 | 59 | private get(url: string): Observable { 60 | return this.makeRequest('GET', url); 61 | } 62 | 63 | private post(url: string, body?: any): Observable { 64 | return this.makeRequest('POST', url, JSON.stringify(body)); 65 | } 66 | 67 | private put(url: string, body?: any): Observable { 68 | return this.makeRequest('PUT', url, JSON.stringify(body)); 69 | } 70 | 71 | private delete(url: string): Observable { 72 | return this.makeRequest('DELETE', url); 73 | } 74 | 75 | private makeRequest(method: string, url: string, body?: any): Observable { 76 | const headers = new Headers({ 77 | 'content-type': 'application/json' 78 | }); 79 | 80 | const config = { 81 | method, 82 | body, 83 | headers, 84 | mode: 'cors' as RequestMode, 85 | credentials: 'include' as RequestCredentials 86 | }; 87 | 88 | const fetchPromise = fetch(`${this.environment.config.api}${url}`, config); 89 | 90 | return fromPromise(fetchPromise) 91 | .pipe( 92 | switchMap(reponse => { 93 | if (reponse.status === 200) { 94 | return fromPromise(reponse.json()) 95 | } 96 | 97 | return of({ 98 | success: false, 99 | error: reponse.statusText 100 | }) 101 | }), 102 | switchMap(response => { 103 | if (response.success) { 104 | return of(response); 105 | } 106 | 107 | return throwError(response); 108 | }) 109 | ); 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /src/player/editor/AceEditor.tsx: -------------------------------------------------------------------------------- 1 | // https://github.com/ajaxorg/ace-builds/issues/129 2 | import * as ace from "brace"; 3 | 4 | import "brace/mode/javascript"; 5 | import "brace/theme/monokai" 6 | import "brace/ext/language_tools" 7 | 8 | import {Component, h} from "preact"; 9 | import {SandboxAutocomplete} from "./SandboxAutocomplete"; 10 | import {Editor} from "brace"; 11 | import {fromEvent, merge, Observable} from "rxjs"; 12 | import {auditTime, filter} from "rxjs/operators"; 13 | import {map, tap} from "rxjs/internal/operators"; 14 | 15 | export interface IPointer { 16 | x: number; 17 | y: number; 18 | } 19 | 20 | interface IComponentState { 21 | } 22 | 23 | interface IProps { 24 | onScroll: (pointer: IPointer) => any; 25 | onChange: (value: string) => any; 26 | onCtrlEnter: () => any; 27 | readonly?: boolean; 28 | } 29 | 30 | export class AceEditor extends Component { 31 | 32 | get ctrlEnter$(): Observable { 33 | return fromEvent(document, 'keydown') 34 | .pipe( 35 | filter(e => this.isWindowsCtrlEnter(e) || this.isUnixCtrlEnter(e) || this.isCtrlS(e)), 36 | tap(e => e.preventDefault()) 37 | ); 38 | } 39 | 40 | get editorScroll$(): Observable { 41 | return merge( 42 | fromEvent(this.editor.session as any, 'changeScrollLeft'), 43 | fromEvent(this.editor.session as any, 'changeScrollTop') 44 | ) 45 | .pipe(map(() => ({ 46 | x: this.editor.session.getScrollLeft(), 47 | y: this.editor.session.getScrollTop() 48 | }))) 49 | .pipe(auditTime(300)); 50 | } 51 | 52 | get change$(): Observable { 53 | return fromEvent(this.editor, 'change') 54 | .pipe(map(() => this.editor.getValue())) 55 | .pipe(auditTime(300)); 56 | } 57 | 58 | private editor: Editor; 59 | 60 | componentDidMount() { 61 | this.change$.subscribe(value => { 62 | this.props.onChange(value); 63 | }); 64 | 65 | this.editorScroll$.subscribe(value => { 66 | this.props.onScroll(value); 67 | }); 68 | 69 | this.ctrlEnter$.subscribe(() => { 70 | this.props.onCtrlEnter(); 71 | }); 72 | 73 | if (this.props.readonly) { 74 | this.editor.$blockScrolling = Infinity; 75 | this.editor.setReadOnly(true); 76 | } 77 | } 78 | 79 | getValue(): string { 80 | return this.editor.getValue(); 81 | } 82 | 83 | setValue(value: string) { 84 | this.editor.setValue(value); 85 | this.editor.clearSelection(); 86 | } 87 | 88 | shouldComponentUpdate(): boolean { 89 | return false; 90 | } 91 | 92 | render(props: IProps, state: IComponentState) { 93 | return ( 94 |
this.initEditor(ref)} /> 95 | ) 96 | } 97 | 98 | scroll(left: number, top: number) { 99 | (this.editor.session as any).setScrollLeft(left); 100 | this.editor.session.setScrollTop(top); 101 | } 102 | 103 | private initEditor(container: HTMLElement) { 104 | const langTools = ace.acequire("ace/ext/language_tools"); 105 | this.editor = ace.edit(container); 106 | 107 | this.editor.session.setMode('ace/mode/javascript'); 108 | this.editor.setTheme('ace/theme/monokai'); 109 | 110 | this.editor.setOptions({ 111 | fontSize: 18, 112 | enableBasicAutocompletion: true, 113 | enableLiveAutocompletion: true 114 | }); 115 | 116 | langTools.addCompleter(new SandboxAutocomplete()); 117 | } 118 | 119 | private isWindowsCtrlEnter(event: KeyboardEvent): boolean { 120 | return event.keyCode === 10 && event.ctrlKey; 121 | } 122 | 123 | private isUnixCtrlEnter(event: KeyboardEvent): boolean { 124 | return event.keyCode === 13 && (event.ctrlKey || event.metaKey); 125 | } 126 | 127 | private isCtrlS(event: KeyboardEvent): boolean { 128 | return event.keyCode === 83 && (event.ctrlKey || event.metaKey); 129 | } 130 | 131 | } -------------------------------------------------------------------------------- /src/common/battle/BattleSession.ts: -------------------------------------------------------------------------------- 1 | import {UnitsStack} from './UnitsStack'; 2 | import {Inject} from '../InjectDectorator'; 3 | import {BattleUnit} from "./BattleUnit"; 4 | import {BattleFieldModel} from "./BattleFieldModel"; 5 | import {BattleSide} from "./BattleSide"; 6 | 7 | export enum WinnerSide { 8 | left = 'left', 9 | right = 'right', 10 | nobody = 'nobody' 11 | } 12 | 13 | export interface ISessionResult { 14 | winner: WinnerSide; 15 | damage: { 16 | left: number; 17 | right: number; 18 | } 19 | } 20 | 21 | export class BattleSession { 22 | 23 | @Inject(UnitsStack) private unitsStack: UnitsStack; 24 | @Inject(BattleFieldModel) private battleFieldModel: BattleFieldModel; 25 | 26 | private winPromise: Promise; 27 | private winResolve: (state: ISessionResult) => any; 28 | private stopResolve: () => any; 29 | private units: BattleUnit[] = []; 30 | private isStopped = false; 31 | private firstLaunch = true; 32 | 33 | start(units: BattleUnit[]): Promise { 34 | this.winPromise = new Promise(resolve => { 35 | this.winResolve = resolve; 36 | }); 37 | 38 | this.units = units; 39 | this.isStopped = false; 40 | 41 | this.unitsStack.init(units); 42 | 43 | this.newTurn(); 44 | 45 | return this.winPromise; 46 | } 47 | 48 | stop(): Promise { 49 | if (this.firstLaunch || this.isStopped) { 50 | this.firstLaunch = false; 51 | 52 | return Promise.resolve(); 53 | } 54 | 55 | this.isStopped = true; 56 | 57 | return new Promise(resolve => { 58 | this.stopResolve = resolve; 59 | }); 60 | } 61 | 62 | getSideDamage(side: BattleSide): number { 63 | return this.units.reduce((summ, unit) => { 64 | return summ + (unit.side === side ? unit.gotDamage : 0); 65 | }, 0); 66 | } 67 | 68 | private newTurn() { 69 | this.unitsStack.next(); 70 | this.runActiveUnitTasks() 71 | .then(() => { 72 | const winnerSide = this.getWinnerSide(); 73 | 74 | if (this.isStopped) { 75 | this.stopResolve(); 76 | return; 77 | } 78 | 79 | if (winnerSide) { 80 | this.winResolve({ 81 | winner: winnerSide, 82 | damage: { 83 | left: this.getSideDamage(BattleSide.right), 84 | right: this.getSideDamage(BattleSide.left) 85 | } 86 | }); 87 | 88 | this.isStopped = true; 89 | 90 | return; 91 | } 92 | 93 | this.newTurn(); 94 | }); 95 | } 96 | 97 | private runActiveUnitTasks(): Promise { 98 | const {activeUnit} = this.unitsStack; 99 | const action = activeUnit.actions.shift(); 100 | 101 | if (action) { 102 | try { 103 | return this.battleFieldModel.doAction(activeUnit, action) 104 | } catch (e) { 105 | return Promise.resolve(); 106 | } 107 | } 108 | 109 | return Promise.resolve(); 110 | } 111 | 112 | private hasAbsoluteWin(side: BattleSide): boolean { 113 | return !this.units.some(unit => unit.side !== side && unit.health > 0) 114 | } 115 | 116 | private noActionsLeft(): boolean { 117 | return !this.units.some(unit => unit.actions.length > 0 && unit.health > 0) 118 | } 119 | 120 | private getWinnerSide(): WinnerSide { 121 | if (this.hasAbsoluteWin(BattleSide.left)) { 122 | return WinnerSide.left; 123 | } 124 | 125 | if (this.hasAbsoluteWin(BattleSide.right)) { 126 | return WinnerSide.right; 127 | } 128 | 129 | if (this.noActionsLeft()) { 130 | const leftDamage = this.getSideDamage(BattleSide.right); 131 | const rightDamage = this.getSideDamage(BattleSide.left); 132 | 133 | if (leftDamage > rightDamage) { 134 | return WinnerSide.left; 135 | } 136 | 137 | if (leftDamage < rightDamage) { 138 | return WinnerSide.right; 139 | } 140 | 141 | return WinnerSide.nobody; 142 | } 143 | 144 | return null; 145 | } 146 | } -------------------------------------------------------------------------------- /src/player/editor/EditorComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Subject} from 'rxjs'; 2 | import {debounceTime, first} from 'rxjs/internal/operators'; 3 | import {Component, h} from "preact"; 4 | import {AceEditor, IPointer} from "./AceEditor"; 5 | import {ClientState} from "../../common/client/ClientState"; 6 | import {Inject} from "../../common/InjectDectorator"; 7 | import {CharactersList} from "../../common/characters/CharactersList"; 8 | import {WebsocketConnection} from "../../common/WebsocketConnection"; 9 | import {IPlayerState, IEditorState} from "../../common/state.model"; 10 | import { Maybe } from '../../common/helpers/Maybe'; 11 | 12 | interface IComponentState { 13 | 14 | } 15 | 16 | interface IProps { 17 | playerState: Partial; 18 | onRunCode: (code: string) => any; 19 | } 20 | 21 | export class EditorComponent extends Component { 22 | 23 | @Inject(ClientState) private clientState: ClientState; 24 | @Inject(CharactersList) private charactersList: CharactersList; 25 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 26 | 27 | private editor: AceEditor; 28 | private nameInput$ = new Subject(); 29 | 30 | componentDidMount() { 31 | const code = Maybe(this.props.playerState).pluck('editor.code').get(); 32 | 33 | if (code) { 34 | this.editor.setValue(code); 35 | } 36 | 37 | this.nameInput$ 38 | .pipe( 39 | debounceTime(300) 40 | ) 41 | .subscribe(name => { 42 | this.clientState.set({name}); 43 | }); 44 | 45 | this.connection.onState$(this.clientState.side) 46 | .pipe(first()) 47 | .subscribe(state => { 48 | if (state.editor && state.editor.code) { 49 | this.editor.setValue(state.editor.code); 50 | } 51 | }) 52 | } 53 | 54 | render(props: IProps, state: IComponentState) { 55 | return ( 56 |
57 |
58 | 59 | this.nameInput$.next((event.target as HTMLInputElement).value)} 62 | /> 63 | 66 |
67 | this.editor = ref} 68 | onChange={value => this.onChangeCode(value)} 69 | onCtrlEnter={() => this.props.onRunCode(this.editor.getValue())} 70 | onScroll={data => this.onEditorScroll(data)} 71 | /> 72 |
73 | ) 74 | } 75 | 76 | private onEditorScroll(data: IPointer) { 77 | this.setEditorState({ 78 | scrollX: data.x, 79 | scrollY: data.y 80 | }) 81 | } 82 | 83 | private onChangeCode(value: string) { 84 | this.setEditorState({ 85 | code: value 86 | }) 87 | } 88 | 89 | private setEditorState(state: Partial) { 90 | const newState = { 91 | editor: Object.assign({}, state) as IEditorState 92 | }; 93 | 94 | this.clientState.set(newState); 95 | } 96 | 97 | private generateSampleCode() { 98 | let sampleCode = ``; 99 | 100 | this.getUniqueIdList() 101 | .forEach(id => { 102 | sampleCode += `// проверка юнита по ID\n` + 103 | `if (is('${id}')) {\n` + 104 | ` // действие\n` + 105 | ` say('Привет, я ${id}!')\n` + 106 | `}\n` 107 | }); 108 | 109 | if (sampleCode === '') { 110 | sampleCode = '// выберите хоть одного юнита' 111 | } 112 | 113 | this.editor.setValue(sampleCode); 114 | } 115 | 116 | private getUniqueIdList(): Set { 117 | const ids = Object.keys(this.clientState.army) 118 | .map(index => this.charactersList.get(this.clientState.army[index])) 119 | .filter(({id}) => id !== 'NULL') 120 | .map(({id}) => id); 121 | 122 | return new Set(ids); 123 | } 124 | } -------------------------------------------------------------------------------- /server/storages/ConnectionsStorage.ts: -------------------------------------------------------------------------------- 1 | import {Master} from "../clients/Master"; 2 | import {LeftPlayer} from "../clients/LeftPlayer"; 3 | import {RightPlayer} from "../clients/RightPlayer"; 4 | import {IPlayerState, IState} from '../../src/common/state.model'; 5 | import {mergeDeep} from '../../src/common/helpers/mergeDeep'; 6 | import {Client} from '../clients/Client'; 7 | import {IClientRegisterMessage} from "../SocketMiddleware"; 8 | import * as ws from 'ws'; 9 | import {Guest} from '../clients/Guest'; 10 | import {BattleState} from '../../src/common/battle/BattleState.model'; 11 | import {Admin} from '../clients/Admin'; 12 | 13 | type Partial = { 14 | [P in keyof T]?: T[P]; 15 | } 16 | 17 | export class ConnectionsStorage { 18 | connections = new Map(); 19 | admin = new Admin(); 20 | guest = new Guest(); 21 | master = new Master(); 22 | leftPlayer = new LeftPlayer(); 23 | rightPlayer = new RightPlayer(); 24 | 25 | state: Partial = {}; 26 | 27 | constructor() { 28 | this.state = this.getInitialState(); 29 | } 30 | 31 | isRegistered(connection: ws): boolean { 32 | return this.connections.has(connection); 33 | } 34 | 35 | registerConnection(data: IClientRegisterMessage, connection: ws): boolean { 36 | switch (data.type) { 37 | case 'registerAdmin': 38 | return this.tryRegisterEntity(connection, 'admin'); 39 | case 'registerGuest': 40 | return this.tryRegisterEntity(connection, 'guest'); 41 | case 'registerMaster': 42 | return this.tryRegisterEntity(connection, 'master'); 43 | case 'registerLeftPlayer': 44 | return this.tryRegisterEntity(connection, 'leftPlayer'); 45 | case 'registerRightPlayer': 46 | return this.tryRegisterEntity(connection, 'rightPlayer'); 47 | } 48 | 49 | return false; 50 | } 51 | 52 | tryRegisterEntity(connection: ws, name: string): boolean { 53 | const client = this.getClient(name); 54 | 55 | if (client.canConnect(connection)) { 56 | if (name === 'leftPlayer') { 57 | this.setState({left: {isConnected: true}}); 58 | } 59 | if (name === 'rightPlayer') { 60 | this.setState({right: {isConnected: true}}); 61 | } 62 | 63 | client.setConnection(connection); 64 | 65 | this.connections.set(connection, name); 66 | 67 | console.log(`${name} registered`); 68 | 69 | return true; 70 | } 71 | 72 | return false; 73 | } 74 | 75 | onConnectionLost(connection: ws) { 76 | if (this.isRegistered(connection)) { 77 | const name = this.connections.get(connection); 78 | const client = this.getClient(name); 79 | 80 | client.disconnect(connection); 81 | this.connections.delete(connection); 82 | 83 | if (name === 'leftPlayer') { 84 | this.setState({left: {isConnected: false}}); 85 | } 86 | if (name === 'rightPlayer') { 87 | this.setState({right: {isConnected: false}}); 88 | } 89 | 90 | console.log(`connection with ${name} lost`); 91 | } 92 | } 93 | 94 | endSession(sessionResult) { 95 | this.master.dispatchSessionResult(sessionResult); 96 | this.leftPlayer.dispatchSessionResult(sessionResult); 97 | this.rightPlayer.dispatchSessionResult(sessionResult); 98 | } 99 | 100 | newSession() { 101 | this.state = this.getInitialState(); 102 | 103 | this.master.dispatchNewSession(); 104 | this.leftPlayer.dispatchNewSession(); 105 | this.rightPlayer.dispatchNewSession(); 106 | } 107 | 108 | setState(newState: Partial) { 109 | this.state = mergeDeep(this.state, newState); 110 | 111 | this.master.setState(this.state); 112 | this.leftPlayer.setState(this.state); 113 | this.rightPlayer.setState(this.state); 114 | } 115 | 116 | close() { 117 | this.connections.forEach((name, connection) => { 118 | if (connection.readyState === ws.OPEN) { 119 | connection.close(); 120 | } 121 | }); 122 | } 123 | 124 | dispatchRoomsChanged() { 125 | this.guest.dispatchRoomsChanged(); 126 | this.admin.dispatchRoomsChanged(); 127 | } 128 | 129 | private getClient(name: string): Client { 130 | return this[name] as Client; 131 | } 132 | 133 | private getInitialState(): Partial { 134 | return { 135 | mode: BattleState.wait 136 | } 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /src/player/PlayerScreen.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from 'preact'; 2 | import {EditorComponent} from "./editor/EditorComponent"; 3 | import {Toolbar} from "./toolbar/Toolbar"; 4 | import {GameDebug} from "./gameDebug/GameDebug"; 5 | import {IPlayerState, IState} from "../common/state.model"; 6 | import {BattleSide} from "../common/battle/BattleSide"; 7 | import {Inject} from "../common/InjectDectorator"; 8 | import {WebsocketConnection} from "../common/WebsocketConnection"; 9 | import {Observable, Subject} from 'rxjs'; 10 | import {ClientState} from '../common/client/ClientState'; 11 | import {PromptService} from '../admin/PromptService'; 12 | import {filter, map, switchMap, tap} from 'rxjs/operators'; 13 | import {ConsoleService} from '../common/console/ConsoleService'; 14 | import {BattleState} from "../common/battle/BattleState.model"; 15 | import {RoomService} from "../common/RoomService"; 16 | import {Environment} from "../common/Environment"; 17 | 18 | interface IComponentState { 19 | playerState: Partial; 20 | state: Partial; 21 | } 22 | 23 | interface IProps { 24 | state: IState; 25 | side: BattleSide; 26 | } 27 | 28 | export class PlayerScreen extends Component { 29 | 30 | @Inject(ClientState) private clientState: ClientState; 31 | @Inject(RoomService) private roomService: RoomService; 32 | @Inject(Environment) private environment: Environment; 33 | @Inject(PromptService) private promptService: PromptService; 34 | @Inject(ConsoleService) private consoleService: ConsoleService; 35 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 36 | 37 | runCode$ = new Subject<[string, string]>(); 38 | pushCode$ = new Subject(); 39 | 40 | state: IComponentState = { 41 | playerState: this.props.state[this.props.side] || {}, 42 | state: this.props.state 43 | }; 44 | 45 | get bothIsReady$(): Observable { 46 | return this.connection.onState$('mode') 47 | .pipe( 48 | map(mode => mode === BattleState.ready || mode === BattleState.results), 49 | filter(result => result) 50 | ); 51 | } 52 | 53 | componentDidMount() { 54 | this.connection.onState$() 55 | .subscribe(state => { 56 | const playerState = state[this.props.side]; 57 | 58 | this.setState({ 59 | playerState, 60 | state 61 | }); 62 | }); 63 | 64 | this.pushCode$ 65 | .pipe( 66 | filter(() => !this.clientState.name), 67 | switchMap(() => this.promptService.prompt('Впишите свое имя')), 68 | tap(({title}) => this.clientState.set({name: title})) 69 | ) 70 | .subscribe(() => { 71 | this.consoleService.infoLog('Кажется, вы готовы к битве! Но код еще можно редактировать =)'); 72 | 73 | this.clientState.set({isReady: true}); 74 | }); 75 | 76 | this.pushCode$ 77 | .pipe( 78 | filter(() => !!this.clientState.name) 79 | ) 80 | .subscribe(() => { 81 | this.consoleService.infoLog('Кажется, вы готовы к битве! Но код еще можно редактировать =)'); 82 | 83 | this.clientState.set({isReady: true}); 84 | }); 85 | 86 | this.bothIsReady$ 87 | .pipe( 88 | switchMap(() => this.promptService.goToMaster()) 89 | ) 90 | .subscribe(() => { 91 | const {roomId} = this.roomService; 92 | const {baseUrl} = this.environment.config; 93 | 94 | // location.href = `${baseUrl}/master/#room=${roomId}`; 95 | }); 96 | } 97 | 98 | render(props: IProps, state: IComponentState) { 99 | return ( 100 |
101 | this.onRunCode(code)} 103 | /> 104 | this.onRunCode(this.clientState.editor.code)} 106 | onSetReady={() => this.pushCode$.next()} /> 107 | 108 |
109 | ); 110 | } 111 | 112 | onRunCode(code: string) { 113 | if (this.props.side === BattleSide.left) { 114 | this.runCode$.next([code, '']) 115 | } else { 116 | this.runCode$.next(['', code]) 117 | } 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /src/common/documentation/UnitApi.tsx: -------------------------------------------------------------------------------- 1 | import {h} from 'preact'; 2 | 3 | export const UnitApi = () => ( 4 |
5 |
6 |
7 |
8 | goTo(x: number, y: number) 9 |
10 |
11 | Переход на клетку x, y 12 |
13 |
14 | 15 |
16 |
17 | relativeGoTo(x: number, y: number) 18 |
19 |
20 | Переход на клетку x, y относительно позиции юнита 21 |
для правого игрока перемещения влево будут с отрицательным x
22 |
23 |
24 | 25 |
26 |
27 | goToEnemyAndHit(id: string) 28 |
29 |
30 | Переход в сторону противника по id и попытка атаки, если это возможно 31 |
пехотинец атакует только если может дотянуться до противника
32 |
33 |
34 | 35 |
36 |
37 | shoot(id: string) 38 |
39 |
40 | Стрелковая атака противника по id 41 |
применимо только к стрелкам
42 |
43 |
44 | 45 |
46 |
47 | spell(id: string) 48 |
49 |
50 | Магическая атака противника по id 51 |
применимо только к магам
52 |
53 |
54 | 55 | {/*
*/} 56 | {/*
*/} 57 | {/*heal(id: string)*/} 58 | {/*
*/} 59 | {/*
*/} 60 | {/*
*/} 61 | 62 |
63 |
64 | say(text: string) 65 |
66 |
67 | Сказать что-нибудь text 68 |
69 |
70 | 71 |
72 |
73 | attackRandom() 74 |
75 |
76 | Берсерк! Случайный выбор юнита на карте и атака! 77 | 78 |
атакует всех кроме юнитов с таким же id
79 |
80 |
81 | 82 |
83 |
84 | isShooter(): boolean 85 |
86 |
87 | Проверка, является ли юнит стрелком 88 |
89 |
90 | 91 |
92 |
93 | isMagician(): boolean 94 |
95 |
96 | Проверка, является ли юнит магом 97 |
98 |
99 | 100 |
101 |
102 | isInfantry(): boolean 103 |
104 |
105 | Проверка, является ли юнит пехотинцем 106 |
107 |
108 | 109 |
110 |
111 | is(id: string): boolean 112 |
113 |
114 | Возвращает true если ID юнита равен id 115 |
116 |
117 |
118 |
119 | ) -------------------------------------------------------------------------------- /src/common/characters/CharactersList.ts: -------------------------------------------------------------------------------- 1 | import {Inject} from "../InjectDectorator"; 2 | import {AnimationsCreator} from "./AnimationsCreator"; 3 | import {BulletType} from "../battle/BulletDrawer"; 4 | import {IAnimationName} from '../battle/BattleUnit'; 5 | 6 | interface IMinMax { 7 | min: number; 8 | max: number; 9 | } 10 | 11 | export interface IAttackTypeConfig { 12 | attack: IMinMax; 13 | defence: IMinMax; 14 | } 15 | 16 | export enum CharacterType { 17 | shooting = 'shooting', 18 | magic = 'magic', 19 | melee = 'melee' 20 | } 21 | 22 | interface IAttackConfigs { 23 | mellee: IAttackTypeConfig; 24 | shoot: IAttackTypeConfig; 25 | magic: IAttackTypeConfig; 26 | } 27 | 28 | export interface ICharacterConfig extends IAttackConfigs { 29 | id: string; 30 | key: string; 31 | title: string; 32 | type: CharacterType; 33 | bulletType: BulletType; 34 | attackAnimation: IAnimationName; 35 | speed: number; 36 | } 37 | 38 | function getCoefficents(num: number): IMinMax { 39 | const min = Math.max(0, num - 2); 40 | const max = num + 2; 41 | 42 | return { 43 | min, max 44 | } 45 | } 46 | 47 | function getAttackConfigs(mellee: number, shoot: number, magic: number): IAttackConfigs { 48 | 49 | const melleeConfig = { 50 | attack: getCoefficents(mellee), 51 | defence: getCoefficents(mellee >> 1) 52 | }; 53 | 54 | const shootConfig = { 55 | attack: getCoefficents(shoot), 56 | defence: getCoefficents(shoot) 57 | }; 58 | 59 | const magicConfig = { 60 | attack: getCoefficents(magic), 61 | defence: getCoefficents(magic) 62 | }; 63 | 64 | 65 | return { 66 | mellee: melleeConfig, 67 | shoot: shootConfig, 68 | magic: magicConfig 69 | } 70 | } 71 | 72 | const NULL_CHARACTER: ICharacterConfig = Object.assign({ 73 | id: 'NULL', 74 | key: 'character_null', 75 | title: 'Null, просто Null', 76 | type: CharacterType.melee, 77 | bulletType: BulletType.snow, 78 | attackAnimation: 'slash', 79 | speed: 0, 80 | }, getAttackConfigs(0, 0, 0)); 81 | 82 | const MAGIC_GIRL: ICharacterConfig = Object.assign({ 83 | id: 'EVAL', 84 | key: 'character_magic', 85 | title: 'кидает файер-боллы, обладает самой сильной магией', 86 | type: CharacterType.magic, 87 | bulletType: BulletType.fire, 88 | attackAnimation: 'spellcast', 89 | speed: 4, 90 | }, getAttackConfigs(0, 4, 8)); 91 | 92 | const SKELETON: ICharacterConfig = Object.assign({ 93 | id: 'PWA', 94 | key: 'character_nekr', 95 | title: 'Стреляет из лука, почти невосприимчив к магии', 96 | type: CharacterType.shooting, 97 | bulletType: BulletType.arrow, 98 | attackAnimation: 'shoot', 99 | speed: 4, 100 | }, getAttackConfigs(2, 6, 8)); 101 | 102 | const ORK: ICharacterConfig = Object.assign({ 103 | id: '$', 104 | key: 'character_ork', 105 | title: 'Старый добрый jQuery, кидает кирпичи', 106 | type: CharacterType.shooting, 107 | bulletType: BulletType.stone, 108 | attackAnimation: 'shoot', 109 | speed: 4, 110 | }, getAttackConfigs(2, 8, 0)); 111 | 112 | const PALLADIN: ICharacterConfig = Object.assign({ 113 | id: 'DART', 114 | key: 'character_palladin', 115 | title: 'Палладин. Лучший в своем роде, но очень медлительный', 116 | type: CharacterType.melee, 117 | bulletType: BulletType.snow, 118 | attackAnimation: 'slash', 119 | speed: 2, 120 | }, getAttackConfigs(8, 6, 0)); 121 | 122 | const VARVAR: ICharacterConfig = Object.assign({ 123 | id: 'CSS', 124 | key: 'character_varvar', 125 | title: 'Простой, но очень быстрый воин с длинным копьем', 126 | type: CharacterType.melee, 127 | bulletType: BulletType.snow, 128 | attackAnimation: 'thrust', 129 | speed: 5, 130 | }, getAttackConfigs(6, 2, 4)); 131 | 132 | const WINTER: ICharacterConfig = Object.assign({ 133 | id: 'IE', 134 | key: 'character_winter', 135 | title: 'Замораживает всех в округе, но погибает от точных выстрелов', 136 | type: CharacterType.magic, 137 | bulletType: BulletType.snow, 138 | attackAnimation: 'spellcast', 139 | speed: 4, 140 | }, getAttackConfigs(6, 0, 6)); 141 | 142 | export class CharactersList { 143 | 144 | types: ICharacterConfig[] = [ 145 | NULL_CHARACTER, 146 | MAGIC_GIRL, 147 | SKELETON, 148 | ORK, 149 | PALLADIN, 150 | VARVAR, 151 | WINTER 152 | ]; 153 | 154 | @Inject(AnimationsCreator) private animationsCreator: AnimationsCreator; 155 | 156 | load(loader: Phaser.Loader.LoaderPlugin) { 157 | this.types.forEach(type => { 158 | loader.atlas(type.key, `img/${type.key}.png`, 'img/sprites.json'); 159 | }); 160 | } 161 | 162 | prepareAnimations(phaserAnims: any) { 163 | this.types.forEach(type => { 164 | this.animationsCreator.create(phaserAnims, type.key); 165 | }); 166 | } 167 | 168 | get(typeId: string): ICharacterConfig { 169 | return this.types.find(type => type.key === typeId); 170 | } 171 | 172 | getRandomType(): string { 173 | const randomIndex = Math.floor(Math.random() * this.types.length); 174 | 175 | return this.types[randomIndex].key; 176 | } 177 | 178 | } -------------------------------------------------------------------------------- /src/common/client/ClientApp.tsx: -------------------------------------------------------------------------------- 1 | import {WebsocketConnection} from '../WebsocketConnection'; 2 | import {Inject, setInject} from '../InjectDectorator'; 3 | import {BattleState} from '../battle/BattleState.model'; 4 | import {BattleSide} from '../battle/BattleSide'; 5 | import {ClientState} from "./ClientState"; 6 | import {LeftArmy} from "../../left/LeftArmy"; 7 | import {EnemyState} from "./EnemyState"; 8 | import {RightArmy} from "../../right/RightArmy"; 9 | import {IPlayerState, IState} from '../state.model'; 10 | import {catchError, distinctUntilChanged, filter, first, map, switchMap} from 'rxjs/internal/operators'; 11 | import {RoomService} from "../RoomService"; 12 | import {BattleGame} from '../battle/BattleGame'; 13 | import {PromptService} from '../../admin/PromptService'; 14 | import {ApiService} from '../ApiService'; 15 | import {Environment} from '../Environment'; 16 | import {render, h} from 'preact'; 17 | import {PlayerScreen} from "../../player/PlayerScreen"; 18 | 19 | export class ClientApp { 20 | 21 | @Inject(BattleGame) private battleGame: BattleGame; 22 | @Inject(EnemyState) private enemyState: EnemyState; 23 | @Inject(ApiService) private apiService: ApiService; 24 | @Inject(ClientState) private clientState: ClientState; 25 | @Inject(RoomService) private roomService: RoomService; 26 | @Inject(Environment) private environment: Environment; 27 | @Inject(PromptService) private promptService: PromptService; 28 | @Inject(WebsocketConnection) private connection: WebsocketConnection; 29 | 30 | constructor(private side: BattleSide) { 31 | this.clientState.side = side; 32 | 33 | if (side === BattleSide.left) { 34 | this.connection.registerAsLeftPlayer(this.roomService.roomId); 35 | 36 | setInject(LeftArmy, this.clientState.army); 37 | setInject(RightArmy, this.enemyState.army); 38 | } else { 39 | this.connection.registerAsRightPlayer(this.roomService.roomId); 40 | 41 | setInject(LeftArmy, this.enemyState.army); 42 | setInject(RightArmy, this.clientState.army); 43 | } 44 | 45 | this.clientState.change$ 46 | .pipe( 47 | distinctUntilChanged((prev, current) => JSON.stringify(prev) === JSON.stringify(current)) 48 | ) 49 | .subscribe(clientState=> { 50 | const newState = { 51 | left: {}, 52 | right: {} 53 | } as IState; 54 | 55 | if (side === BattleSide.left) { 56 | Object.assign(newState.left, clientState); 57 | } else { 58 | Object.assign(newState.right, clientState); 59 | } 60 | 61 | this.connection.sendState(newState, this.roomService.roomId); 62 | }); 63 | 64 | this.connection.onMessage$.subscribe(message => { 65 | if (message.type === 'state') { 66 | this.battleGame.setState(message.data); 67 | } 68 | 69 | if (message.type === 'endSession') { 70 | const sessionResult = message.data.sessionResult; 71 | 72 | if (sessionResult.winner.toString() === this.clientState.side) { 73 | this.battleGame.showWinnerScreen(message.data.sessionResult); 74 | } else { 75 | this.battleGame.showLoseScreen(message.data.sessionResult); 76 | } 77 | } 78 | 79 | if (message.type === 'newSession') { 80 | location.reload(); 81 | } 82 | }); 83 | 84 | this.connection.onClose$.subscribe(() => { 85 | this.battleGame.setState(BattleState.connectionClosed); 86 | 87 | const {roomId} = this.roomService; 88 | 89 | this.apiService.getRoom(roomId) 90 | .pipe( 91 | map(() => false), 92 | catchError(() => [true]), 93 | filter(response => response), 94 | switchMap(() => this.promptService.alert('Ошибка', 'Данная комната больше не существует')) 95 | ) 96 | .subscribe(() => { 97 | location.href = this.environment.config.baseUrl; 98 | }); 99 | }); 100 | 101 | const enemySide = side === BattleSide.left ? BattleSide.right : BattleSide.left; 102 | 103 | this.connection.onState$(enemySide).subscribe(state => { 104 | this.enemyState.set(state); 105 | 106 | if (enemySide === BattleSide.left && state.army) { 107 | setInject(LeftArmy, state.army) 108 | } 109 | 110 | if (enemySide === BattleSide.right && state.army) { 111 | setInject(RightArmy, state.army) 112 | } 113 | }); 114 | 115 | this.connection.onState$(side).subscribe(state => { 116 | this.clientState.setFromServer(state || {}); 117 | 118 | if (side === BattleSide.left && state.army) { 119 | setInject(LeftArmy, state.army) 120 | } 121 | 122 | if (side === BattleSide.right && state.army) { 123 | setInject(RightArmy, state.army) 124 | } 125 | 126 | }); 127 | 128 | this.connection.onState$() 129 | .pipe(first()) 130 | .subscribe(state => { 131 | render((), document.querySelector('.player')); 132 | }); 133 | 134 | } 135 | } -------------------------------------------------------------------------------- /server/ApiController.ts: -------------------------------------------------------------------------------- 1 | import {Request, Router} from "express"; 2 | import * as passport from 'passport'; 3 | import {Inject} from "../src/common/InjectDectorator"; 4 | import {LeaderBoard} from "./storages/LeaderBoard"; 5 | import {RoomStorage} from "./storages/RoomStorage"; 6 | import {RoomModel} from "./models/RoomModel"; 7 | import {IApiFullResponse} from './models/IApiFullResponse.model'; 8 | import {ConnectionsStorage} from './storages/ConnectionsStorage'; 9 | import {BattleState} from '../src/common/battle/BattleState.model'; 10 | import {AuthController} from "./AuthController"; 11 | 12 | export class ApiController { 13 | @Inject(LeaderBoard) private leaderBoard: LeaderBoard; 14 | @Inject(RoomStorage) private roomStorage: RoomStorage; 15 | @Inject(AuthController) private authController: AuthController; 16 | @Inject(ConnectionsStorage) private guestConnectionsStorage: ConnectionsStorage; 17 | 18 | constructor(public router: Router) { 19 | 20 | passport.use(this.authController.cookiesMiddleware()); 21 | passport.use(this.authController.localMiddleware()); 22 | 23 | router.get('/authData', this.authController.checkAuth(), (req, response) => { 24 | const output = this.getSafeResult(() => req.user); 25 | 26 | response.json(output); 27 | }); 28 | 29 | router.post('/logout', this.authController.checkAuth(), (req, response) => { 30 | const output = this.getSafeResult(() => 'OK'); 31 | 32 | response.clearCookie('token'); 33 | 34 | response.json(output); 35 | }); 36 | 37 | router.post('/login', this.authController.authenticate(), (req, response) => { 38 | const output = this.getSafeResult(() => req.user); 39 | 40 | response.cookie('token', this.authController.lastToken, { 41 | maxAge: 1000 * 60 * 60 * 24, 42 | httpOnly: true, 43 | signed: true 44 | }); 45 | 46 | response.json(output); 47 | }); 48 | 49 | router.get('/leaderboard', (request, response) => { 50 | const output = this.getSafeResult(() => this.leaderBoard.data); 51 | 52 | response.json(output); 53 | }); 54 | 55 | router.get('/rooms', (request, response) => { 56 | const output = this.getSafeResult(() => { 57 | const rooms = this.roomStorage.getAll(); 58 | const result = {}; 59 | 60 | if (request.query.isAll) { 61 | Object.keys(rooms).forEach(name => { 62 | const room = rooms[name]; 63 | 64 | result[name] = new RoomModel(room); 65 | }); 66 | 67 | } else { 68 | Object.keys(rooms).forEach(name => { 69 | const room = rooms[name]; 70 | 71 | if (room.state.mode !== BattleState.wait) { 72 | result[name] = new RoomModel(room); 73 | } 74 | }); 75 | } 76 | 77 | return result; 78 | }); 79 | 80 | response.json(output); 81 | }); 82 | 83 | router.post('/rooms/:id', (request, response) => { 84 | const output = this.getSafeResult(() => { 85 | this.checkTokenOrThrow(request); 86 | 87 | this.roomStorage.createNew(request.params.id, request.body.title); 88 | 89 | return 'OK'; 90 | }); 91 | 92 | response.json(output); 93 | }); 94 | 95 | router.post('/saveRoomState', (request, response) => { 96 | const output = this.getSafeResult(() => { 97 | this.checkTokenOrThrow(request); 98 | 99 | this.roomStorage.saveState(); 100 | 101 | return 'OK'; 102 | }); 103 | 104 | response.json(output); 105 | }); 106 | 107 | router.post('/rooms/:id/reload', (request, response) => { 108 | const output = this.getSafeResult(() => { 109 | this.checkTokenOrThrow(request); 110 | 111 | this.roomStorage.reloadRoomSession(request.params.id); 112 | 113 | return 'OK'; 114 | }); 115 | 116 | response.json(output); 117 | }); 118 | 119 | router.delete('/rooms/:id', (request, response) => { 120 | const output = this.getSafeResult(() => { 121 | this.checkTokenOrThrow(request); 122 | 123 | this.roomStorage.delete(request.params.id); 124 | 125 | return 'OK'; 126 | }); 127 | 128 | response.json(output); 129 | }); 130 | 131 | router.get('/rooms/:id', (request, response) => { 132 | const output = this.getSafeResult(() => { 133 | return new RoomModel(this.roomStorage.get(request.params.id));; 134 | }); 135 | 136 | response.json(output); 137 | }); 138 | } 139 | 140 | private getSafeResult(dataCallback: () => any): IApiFullResponse { 141 | let result = null; 142 | let error = null; 143 | let success = false; 144 | 145 | try { 146 | success = true; 147 | result = dataCallback(); 148 | } catch (e) { 149 | success = false; 150 | error = e.message 151 | } 152 | 153 | return {result, success, error}; 154 | } 155 | 156 | private checkTokenOrThrow(request: Request) { 157 | const token = request.query.token || request.params.token || request.body.token; 158 | 159 | if (!this.guestConnectionsStorage.admin.checkToken(token)) { 160 | console.log('Invalid token:', request.method, request.url); 161 | 162 | throw Error('Invalid token'); 163 | } 164 | } 165 | 166 | } -------------------------------------------------------------------------------- /src/common/codeSandbox/CodeSandbox.ts: -------------------------------------------------------------------------------- 1 | import {BattleUnit} from "../battle/BattleUnit"; 2 | import {getUnitApi} from './getUnitApi'; 3 | import {ConsoleService, MessageType} from "../console/ConsoleService"; 4 | import {Inject} from "../InjectDectorator"; 5 | import {fromEvent} from "rxjs/internal/observable/fromEvent"; 6 | import {timer} from "rxjs/internal/observable/timer"; 7 | import {catchError, filter, map, switchMap, takeUntil} from "rxjs/operators"; 8 | import {merge} from "rxjs/internal/observable/merge"; 9 | import {throwError} from "rxjs/internal/observable/throwError"; 10 | import {getRandomSeed, LCG} from '../helpers/random'; 11 | 12 | export interface IAction { 13 | action: string; 14 | id?: any; 15 | x?: any; 16 | y?: any; 17 | text?: any; 18 | } 19 | 20 | interface IWorkerResponse { 21 | type: MessageType; 22 | data: any; 23 | } 24 | 25 | const MAX_EVAL_TIMEOUT = 1000; 26 | 27 | export class CodeSandbox { 28 | 29 | @Inject(ConsoleService) private consoleService: ConsoleService; 30 | 31 | eval(code: string, unit: BattleUnit): Promise { 32 | const worker = new Worker(this.getJSBlob(code)); 33 | 34 | const message$ = fromEvent(worker, 'message') 35 | .pipe(map(e => JSON.parse(e.data))); 36 | 37 | const successMessage$ = message$ 38 | .pipe(filter(({type}) => type === MessageType.Success)); 39 | 40 | const timeoutClose$ = timer(MAX_EVAL_TIMEOUT).pipe( 41 | takeUntil(successMessage$), 42 | switchMap(() => throwError(`Скрипт исполнялся более 1 секунды и был остановлен!`)) 43 | ); 44 | 45 | return new Promise((resolve, reject) => { 46 | 47 | message$ 48 | .pipe( 49 | takeUntil(successMessage$), 50 | takeUntil(timeoutClose$) 51 | ) 52 | .subscribe(message => { 53 | this.consoleService.next({ 54 | source: message.type, 55 | text: message.data.join() 56 | }) 57 | }); 58 | 59 | merge(timeoutClose$, successMessage$) 60 | .pipe( 61 | map(e => e.data), 62 | catchError(message => { 63 | this.consoleService.vmLog(message); 64 | reject(message); 65 | worker.terminate(); 66 | 67 | return []; 68 | }) 69 | ) 70 | .subscribe(data => { 71 | resolve(data); 72 | worker.terminate(); 73 | }); 74 | 75 | worker.postMessage(unit.state); // Start the worker. 76 | }); 77 | } 78 | 79 | private getWorkerCode(codeToInject: string): string { 80 | const randomSeed = getRandomSeed(codeToInject); 81 | 82 | return ` 83 | onmessage = (message) => { 84 | 85 | const actions = []; 86 | const unit = message.data; 87 | 88 | Math.random = ${LCG.toString()}(${randomSeed}); 89 | 90 | const unitApi = (${getUnitApi.toString()})(unit, actions); 91 | const apis = {console, Math, parseInt, parseFloat, Object, JSON}; 92 | const nativePostMessage = this.postMessage; 93 | 94 | ['log', 'info', 'warn', 'error'].forEach(patchConsoleMethod); 95 | 96 | const sandboxProxy = new Proxy(Object.assign(unitApi, apis), {has, get}); 97 | 98 | Object.keys(this).forEach(key => { 99 | delete this[key]; 100 | }); 101 | 102 | this.Function = function() { return {'неплохо': 'неплохо =)'} }; 103 | 104 | with (sandboxProxy) { 105 | (function() { 106 | try { 107 | ${codeToInject}; 108 | } catch (e) { 109 | console.error(e); 110 | } 111 | }).call({"слишком": 'просто'}) 112 | } 113 | 114 | function has (target, key) { 115 | return true; 116 | } 117 | 118 | function get (target, key) { 119 | if (key === Symbol.unscopables) return undefined; 120 | return target[key]; 121 | } 122 | 123 | function patchConsoleMethod(name) { 124 | const nativeMethod = console[name].bind(console); 125 | 126 | console[name] = (...attributes) => { 127 | attributes = attributes.map(attr => { 128 | if (attr instanceof Error) { 129 | return attr.constructor.name + ': ' + attr.message; 130 | } 131 | 132 | if (attr instanceof Object) { 133 | return JSON.stringify(attr); 134 | } 135 | 136 | return attr; 137 | }) 138 | 139 | nativePostMessage(JSON.stringify({type: name, data: attributes})); 140 | 141 | nativeMethod(...attributes); 142 | } 143 | } 144 | 145 | nativePostMessage(JSON.stringify({type: 'success', data: actions})); 146 | }`; 147 | } 148 | 149 | private getJSBlob(jsCode: string): any { 150 | const blob = new Blob([this.getWorkerCode(jsCode)], { type: "text/javascript" }); 151 | 152 | return URL.createObjectURL(blob); 153 | } 154 | 155 | } -------------------------------------------------------------------------------- /server/Room.ts: -------------------------------------------------------------------------------- 1 | import {ConnectionsStorage} from "./storages/ConnectionsStorage"; 2 | import {IPlayerState, IState} from "../src/common/state.model"; 3 | import {IClientRegisterMessage} from "./SocketMiddleware"; 4 | import * as ws from 'ws'; 5 | import {forkJoin, merge, Observable, Subject} from 'rxjs'; 6 | import {filter} from 'rxjs/operators'; 7 | import {IMessage} from '../src/common/WebsocketConnection'; 8 | import {LeaderBoard} from './storages/LeaderBoard'; 9 | import {Inject} from '../src/common/InjectDectorator'; 10 | import {Maybe} from "../src/common/helpers/Maybe"; 11 | import {BattleState} from '../src/common/battle/BattleState.model'; 12 | import {first} from 'rxjs/internal/operators'; 13 | import {mergeDeep} from '../src/common/helpers/mergeDeep'; 14 | 15 | export class Room { 16 | 17 | onMessage$ = new Subject(); 18 | 19 | @Inject(LeaderBoard) private leaderBoard: LeaderBoard; 20 | @Inject(ConnectionsStorage) private guestConnectionsStorage: ConnectionsStorage; 21 | 22 | private connectionsStorage = new ConnectionsStorage(); 23 | 24 | get watchersCount(): number { 25 | let result = 0; 26 | 27 | this.connectionsStorage.connections.forEach(name => { 28 | if (name === 'master') { 29 | result++; 30 | } 31 | }); 32 | 33 | return result; 34 | } 35 | 36 | get state(): Partial { 37 | return this.connectionsStorage.state; 38 | } 39 | 40 | set state(state: Partial) { 41 | this.connectionsStorage.setState(state); 42 | } 43 | 44 | constructor(public title: string) { 45 | this.connectionsStorage.setState({roomTitle: title}); 46 | 47 | this.on$('sendWinner') 48 | .pipe(filter(() => this.state.mode !== BattleState.results)) 49 | .subscribe(data => { 50 | const state = Object.assign({}, data.sessionResult, this.state, { 51 | mode: BattleState.results, 52 | endTime: Date.now() 53 | }); 54 | 55 | this.connectionsStorage.setState(state); 56 | this.leaderBoard.write(state); 57 | this.connectionsStorage.endSession(data.sessionResult); 58 | }); 59 | 60 | this.on$('newSession').subscribe(data => { 61 | this.connectionsStorage.newSession(); 62 | }); 63 | 64 | this.on$('state').subscribe(data => { 65 | const isAllReady = this.isAllPlayersReady(data.state); 66 | let modeIsChanged = false; 67 | 68 | if (isAllReady && (this.state.mode !== BattleState.ready && this.state.mode !== BattleState.results)) { 69 | data.state.mode = BattleState.ready; 70 | 71 | modeIsChanged = true; 72 | } 73 | 74 | this.connectionsStorage.setState(data.state); 75 | 76 | const leftUpdated = this.isNeedToUpdateRooms(data.state.left); 77 | const rightUpdated = this.isNeedToUpdateRooms(data.state.right); 78 | 79 | if (leftUpdated || rightUpdated || modeIsChanged) { 80 | this.guestConnectionsStorage.dispatchRoomsChanged(); 81 | } 82 | }); 83 | 84 | merge( 85 | this.connectionsStorage.leftPlayer.onMessage$, 86 | this.connectionsStorage.rightPlayer.onMessage$, 87 | this.connectionsStorage.master.onMessage$ 88 | ) 89 | .subscribe(message => { 90 | this.onMessage$.next(message); 91 | }); 92 | 93 | this.onSessionLoad(); 94 | } 95 | 96 | closeConnections() { 97 | this.connectionsStorage.close(); 98 | } 99 | 100 | onConnectionLost(connection: ws) { 101 | this.connectionsStorage.onConnectionLost(connection); 102 | } 103 | 104 | tryRegisterConnection(data: IClientRegisterMessage, connection: ws) { 105 | if (!this.connectionsStorage.isRegistered(connection)) { 106 | this.connectionsStorage.registerConnection(data, connection); 107 | 108 | if (!this.connectionsStorage.isRegistered(connection)) { 109 | connection.close(); 110 | 111 | return; 112 | } 113 | 114 | this.guestConnectionsStorage.dispatchRoomsChanged(); 115 | 116 | return; 117 | } 118 | } 119 | 120 | reloadSession() { 121 | this.connectionsStorage.newSession(); 122 | this.onSessionLoad(); 123 | } 124 | 125 | private onSessionLoad() { 126 | merge( 127 | this.connectionsStorage.leftPlayer.registered$, 128 | this.connectionsStorage.rightPlayer.registered$ 129 | ) 130 | .pipe(first()) 131 | .subscribe(() => { 132 | this.connectionsStorage.setState({ 133 | createTime: Date.now() 134 | }); 135 | 136 | this.guestConnectionsStorage.dispatchRoomsChanged(); 137 | }); 138 | 139 | forkJoin( 140 | this.connectionsStorage.leftPlayer.registered$.pipe(first()), 141 | this.connectionsStorage.rightPlayer.registered$.pipe(first()) 142 | ) 143 | .subscribe(() => { 144 | this.connectionsStorage.setState({ 145 | mode: BattleState.codding 146 | }); 147 | 148 | this.guestConnectionsStorage.dispatchRoomsChanged(); 149 | }); 150 | } 151 | 152 | private on$(event: string): Observable { 153 | return this.onMessage$ 154 | .pipe(filter(message => message.type === event)); 155 | } 156 | 157 | private isAllPlayersReady(newState: Partial): boolean { 158 | const mergedState = mergeDeep({}, this.state, newState); 159 | 160 | const leftIsReady = Maybe(mergedState).pluck('left.isReady').getOrElse(false); 161 | const rightIsReady = Maybe(mergedState).pluck('right.isReady').getOrElse(false); 162 | 163 | return leftIsReady && rightIsReady; 164 | } 165 | 166 | private isNeedToUpdateRooms(playerState: Partial = {}): boolean { 167 | const fields = ['name', 'isReady']; 168 | 169 | return fields.some(key => key in playerState); 170 | } 171 | } --------------------------------------------------------------------------------