diff --git a/packages/client/debug.html b/packages/client/debug.html new file mode 100644 index 0000000..d2008ae --- /dev/null +++ b/packages/client/debug.html @@ -0,0 +1,13 @@ + + + + + + + RTS - Debug view + + +
+ + + diff --git a/packages/client/src/App.css b/packages/client/src/App.css index cd19ce6..d713369 100644 --- a/packages/client/src/App.css +++ b/packages/client/src/App.css @@ -67,6 +67,8 @@ border-radius: 10px 0 0 0; box-shadow: 0px 0px 37px 0px rgba(0,0,0,0.5); + + overflow: hidden; } .CommandPaletteHint { @@ -81,6 +83,7 @@ backdrop-filter: blur(10px); background-color: rgba(150, 150, 150, 0.3); color: white; + overflow: hidden; box-shadow: 0px 0px 37px 0px rgba(0,0,0,0.5); } diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index efa2fd1..2908dd1 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from 'react' import './App.css' -import { MatchList } from './MatchList'; -import { Minimap } from './Minimap'; +import { MatchList } from './components/MatchList'; +import { Minimap } from './components/Minimap'; import { CommandPalette, SelectedAction } from './components/CommandPalette'; import { BottomUnitView } from './components/BottomUnitView'; import { ResourceView } from './components/ResourceView'; @@ -41,6 +41,12 @@ function App() { .then(s => setServerState(s)); }, []); + const leaveMatch = async () => { + await multiplayer.leaveMatch(); + setLastUpdatePacket(null); + setServerState(null); + }; + useEffect(() => { multiplayer.setup({ onUpdatePacket: (p:UpdatePacket) => { @@ -68,7 +74,7 @@ function App() { const [selectedUnits, setSelectedUnits] = useState(new Set()); - const mapClick = useCallback((p: Position, button: number) => { + const mapClick = useCallback((p: Position, button: number, shift: boolean) => { if (selectedUnits.size === 0) return; @@ -78,15 +84,17 @@ function App() { if (!selectedAction) { break; } else if (selectedAction.action === 'Move') { - multiplayer.moveCommand(Array.from(selectedUnits), p); + multiplayer.moveCommand(Array.from(selectedUnits), p, shift); } else if (selectedAction.action === 'Attack') { - multiplayer.attackMoveCommand(Array.from(selectedUnits), p); + multiplayer.attackMoveCommand(Array.from(selectedUnits), p, shift); } else if (selectedAction.action === 'Build') { - multiplayer.buildCommand(Array.from(selectedUnits), selectedAction.building, p); + // Only send one harvester to build + // TODO send the closest one + multiplayer.buildCommand([selectedUnits.keys().next().value], selectedAction.building, p, shift); } break; case 2: - multiplayer.moveCommand(Array.from(selectedUnits), p); + multiplayer.moveCommand(Array.from(selectedUnits), p, shift); break; } @@ -95,7 +103,7 @@ function App() { }, [selectedAction, selectedUnits]); // TODO it feels like it shouldn't be be here, maybe GameController component? - const unitClick = useCallback((targetId: UnitId, button: number) => { + const unitClick = useCallback((targetId: UnitId, button: number, shift: boolean) => { if (!lastUpdatePacket) return; @@ -112,7 +120,21 @@ function App() { switch (button) { case 0: if (!selectedAction) { - setSelectedUnits(new Set([targetId])); + if (shift) { + // shift-click means add if not there, but remove if there + setSelectedUnits(prev => { + const units = new Set(prev); + if (units.has(targetId)) { + units.delete(targetId); + } + else { + units.add(targetId); + } + return units; + }); + } else { + setSelectedUnits(new Set([targetId])); + } break; } @@ -121,30 +143,34 @@ function App() { } if (selectedAction.action === 'Move') { - multiplayer.followCommand(Array.from(selectedUnits), targetId); + multiplayer.followCommand(Array.from(selectedUnits), targetId, shift); } else if (selectedAction.action === 'Attack') { - multiplayer.attackCommand(Array.from(selectedUnits), targetId); + multiplayer.attackCommand(Array.from(selectedUnits), targetId, shift); } break; case 2: // TODO properly understand alliances if (target.owner === 0) { // neutral // TODO actually check if can harvest and is resource - multiplayer.harvestCommand(Array.from(selectedUnits), targetId); + multiplayer.harvestCommand(Array.from(selectedUnits), targetId, shift); } else if (target.owner === multiplayer.getPlayerIndex()) { - multiplayer.followCommand(Array.from(selectedUnits), targetId); + multiplayer.followCommand(Array.from(selectedUnits), targetId, shift); } else if (target.owner !== multiplayer.getPlayerIndex()) { - multiplayer.attackCommand(Array.from(selectedUnits), targetId); + multiplayer.attackCommand(Array.from(selectedUnits), targetId, shift); } break; } }, [lastUpdatePacket, selectedAction, selectedUnits]); - const boardSelectUnits = (units: Set) => { + const boardSelectUnits = (newUnits: Set, shift: boolean) => { setSelectedAction(undefined); - setSelectedUnits(units); + if (shift) { + setSelectedUnits(units => new Set([...units, ...newUnits])); + } else { + setSelectedUnits(newUnits); + } }; // TODO track key down state for stuff like a-move clicks @@ -164,13 +190,21 @@ function App() { } }, [selectedAction, selectedUnits]); - const style = selectedAction ? { cursor: "pointer"} : { }; + const appDivStyle = selectedAction ? { cursor: "pointer"} : { }; + + const showGame = + serverState && + lastUpdatePacket && + ( lastUpdatePacket.state.id === 'Precount'|| + lastUpdatePacket.state.id === 'Play' || + lastUpdatePacket.state.id === 'Paused' + ); return ( -
+
{ multiplayer.sendChatMessage("lol")} + sendMessage={(msg) => multiplayer.sendChatMessage(msg)} messages={messages} /> } @@ -187,7 +221,10 @@ function App() { back to your game.

GLHF!


- multiplayer.joinMatch(matchId)} /> + multiplayer.joinMatch(matchId)} + spectateMatch={matchId => multiplayer.spectateMatch(matchId)} + />
@@ -213,24 +250,29 @@ function App() {
} - { serverState && - lastUpdatePacket && - (lastUpdatePacket.state.id === 'Precount' || lastUpdatePacket.state.id === 'Play' || lastUpdatePacket.state.id === 'Paused') - && + { + serverState && <> - + { showMainMenu &&

Main menu

You are player #{multiplayer.getPlayerIndex()}

{ !serverState && } - { serverState && } + { serverState && } { serverState && } { lastUpdatePacket && } { serverState && }
} - + + } + + { showGame && + <>

Game Over

- +
} diff --git a/packages/client/src/DebugApp.tsx b/packages/client/src/DebugApp.tsx deleted file mode 100644 index 5932605..0000000 --- a/packages/client/src/DebugApp.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' - -import { Game, CommandPacket, IdentificationPacket, UpdatePacket, UnitId, Position } from 'server/types' -import { Multiplayer } from './Multiplayer'; -const multiplayer = new Multiplayer("bananu7"); - -export function DebugApp() { - const [lastUpdatePacket, setLastUpdatePacket] = useState(null); - return ( -
-

Debug view

- {lastUpdatePacket ? JSON.stringify(lastUpdatePacket) : ""} -
- ); -} \ No newline at end of file diff --git a/packages/client/src/Multiplayer.ts b/packages/client/src/Multiplayer.ts index 9521ab3..2242b2e 100644 --- a/packages/client/src/Multiplayer.ts +++ b/packages/client/src/Multiplayer.ts @@ -68,6 +68,12 @@ export class Multiplayer { this.onUpdatePacket && this.onUpdatePacket(u); }) + this.channel.on('spectating', (data: Data) => { + this.matchId = (data as {matchId: string}).matchId; + localStorage.setItem('matchId', this.matchId); + this.onMatchConnected && this.onMatchConnected(this.matchId); + }); + this.channel.on('connected', (data: Data) => { if (!this.matchId) { throw "Server responded with connection but the multiplayer isn't initialized to a match"; @@ -139,6 +145,18 @@ export class Multiplayer { this.channel.emit('connect', data); }); }; + + spectateMatch(matchId: string) { + console.log(`[Multiplayer] spectating match ${matchId}`) + const data : IdentificationPacket = { + userId: this.userId, + matchId + }; + + this.channel.emit('spectate', data); + } + + // TODO - no way to stop spectating async leaveMatch() { if (!this.matchId) @@ -179,14 +197,14 @@ export class Multiplayer { this.channel.emit('chat message', 'msg') } - moveCommand(unitIds: UnitId[], target: Position) { + moveCommand(unitIds: UnitId[], target: Position, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'Move', target }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd) } @@ -202,38 +220,38 @@ export class Multiplayer { this.channel.emit('command', cmd) } - followCommand(unitIds: UnitId[], target: UnitId) { + followCommand(unitIds: UnitId[], target: UnitId, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'Follow', target }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd); } - attackCommand(unitIds: UnitId[], target: UnitId) { + attackCommand(unitIds: UnitId[], target: UnitId, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'Attack', target }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd); } - attackMoveCommand(unitIds: UnitId[], target: Position) { + attackMoveCommand(unitIds: UnitId[], target: Position, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'AttackMove', target }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd); } @@ -250,7 +268,7 @@ export class Multiplayer { this.channel.emit('command', cmd); } - buildCommand(unitIds: UnitId[], building: string, position: Position) { + buildCommand(unitIds: UnitId[], building: string, position: Position, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'Build', @@ -258,19 +276,19 @@ export class Multiplayer { position }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd); } - harvestCommand(unitIds: UnitId[], target: UnitId) { + harvestCommand(unitIds: UnitId[], target: UnitId, shift: boolean) { const cmd : CommandPacket = { action: { typ: 'Harvest', target, }, unitIds, - shift: false, + shift, }; this.channel.emit('command', cmd); } diff --git a/packages/client/src/MatchList.tsx b/packages/client/src/components/MatchList.tsx similarity index 71% rename from packages/client/src/MatchList.tsx rename to packages/client/src/components/MatchList.tsx index 2e69dd0..d11f568 100644 --- a/packages/client/src/MatchList.tsx +++ b/packages/client/src/components/MatchList.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react' import { MatchInfo } from 'server/types' -import { HTTP_API_URL } from './config' +import { HTTP_API_URL } from '../config' type Props = { joinMatch: (matchId: string) => void; + spectateMatch: (matchId: string) => void; } export function MatchList(props: Props) { @@ -25,18 +26,21 @@ export function MatchList(props: Props) { }; }, []); - const matchRows = matches.map(m => - + const matchRows = matches.map(m => { + const joinable = m.status.id == "Lobby"; + + return ( {m.matchId} {m.playerCount} {m.status.id} - { m.status.id == "Lobby" && - - } + - - ); + + + + ); + }); return ( diff --git a/packages/client/src/Minimap.tsx b/packages/client/src/components/Minimap.tsx similarity index 100% rename from packages/client/src/Minimap.tsx rename to packages/client/src/components/Minimap.tsx diff --git a/packages/client/src/debug/DebugApp.tsx b/packages/client/src/debug/DebugApp.tsx new file mode 100644 index 0000000..c039f41 --- /dev/null +++ b/packages/client/src/debug/DebugApp.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect, useCallback, CSSProperties } from 'react' + +import { Game, CommandPacket, IdentificationPacket, UpdatePacket, UnitId, Position } from 'server/types' +import { Multiplayer } from '../Multiplayer'; +import { Board, UnitState } from 'server/types' +import { MatchList } from '../components/MatchList'; + +const multiplayer = new Multiplayer("debug_user"); + +type Props = { + board: Board, + units: UnitState[], +} +export function DebugMap(props: Props) { + const style : CSSProperties = { + position: 'absolute', + left: 0, + bottom: 0, + width: '100%', + height: '100%', + backgroundColor: '#114411', + boxSizing: 'border-box', + overflow: 'hidden', + }; + + const unitStyle = { + fill:'blue', + strokeWidth: 1, + stroke: 'black' + }; + + const contents = props.units.map(u => { + const ownerToColor = (owner: number) => { + switch(owner) { + case 0: return "#dddddd"; + case 1: return "#3333ff"; + case 2: return "#ee1111"; + } + }; + const color = ownerToColor(u.owner); + + const size = u.kind === 'Base' ? '8' : '3'; + return (); + }); + + // TODO proper scaling to map size + return ( +
+ + {contents} + +
+ ); +} + +export default function DebugApp() { + const [lastUpdatePacket, setLastUpdatePacket] = useState(null); + + const [serverState, setServerState] = useState(null); + const refresh = () => { + multiplayer.getMatchState() + .then(s => setServerState(s)); + }; + + useEffect(() => { + multiplayer.setup({ + onUpdatePacket: (p:UpdatePacket) => { + setLastUpdatePacket(p); + }, + onMatchConnected: (matchId: string) => { + console.log(`[App] Connected to a match ${matchId}`); + refresh(); + } + }); + }); + + return ( +
+

Debug view

+ + multiplayer.joinMatch(matchId)} + spectateMatch={matchId => multiplayer.spectateMatch(matchId)} + /> + {lastUpdatePacket ? JSON.stringify(lastUpdatePacket) : ""} + { + serverState && + lastUpdatePacket && + + } +
+ ); +} \ No newline at end of file diff --git a/packages/client/src/debug/debug.tsx b/packages/client/src/debug/debug.tsx new file mode 100644 index 0000000..e88c15a --- /dev/null +++ b/packages/client/src/debug/debug.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import DebugApp from './DebugApp' +import '../index.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) \ No newline at end of file diff --git a/packages/client/src/gfx/Board3D.tsx b/packages/client/src/gfx/Board3D.tsx index 04c19e6..4d46abd 100644 --- a/packages/client/src/gfx/Board3D.tsx +++ b/packages/client/src/gfx/Board3D.tsx @@ -38,11 +38,12 @@ export interface Props { board: Board; playerIndex: number; unitStates: UnitState[]; - select: (ids: Set) => void; selectedUnits: Set; selectedAction: SelectedAction | undefined; - mapClick: (p: Position, button: number) => void; - unitClick: (u: UnitId, button: number) => void; + + select: (ids: Set, shift: boolean) => void; + mapClick: (p: Position, button: number, shift: boolean) => void; + unitClick: (u: UnitId, button: number, shift: boolean) => void; } export function Board3D(props: Props) { @@ -59,7 +60,7 @@ export function Board3D(props: Props) { const groupRef = useRef(null); - const selectInBox = (box: Box) => { + const selectInBox = (box: Box, shift: boolean) => { // TODO - this is a hotfix; Board shouldn't make those decisions... if (props.selectedAction) return; @@ -77,7 +78,7 @@ export function Board3D(props: Props) { .filter(u => u.owner === props.playerIndex) .map(u => u.id); - props.select(new Set(selection)); + props.select(new Set(selection), shift); }; useEffect(() => { diff --git a/packages/client/src/gfx/Map3D.tsx b/packages/client/src/gfx/Map3D.tsx index 5dec49d..004878b 100644 --- a/packages/client/src/gfx/Map3D.tsx +++ b/packages/client/src/gfx/Map3D.tsx @@ -9,14 +9,14 @@ import * as THREE from 'three'; import { Board, Unit, GameMap, UnitId, Position, UnitState } from 'server/types' -type Click = (p: Position, button: number) => void; +type Click = (p: Position, button: number, shift: boolean) => void; type RawClick = (e: ThreeEvent) => void; export type Box = { x1: number, y1: number, x2: number, y2: number }; type Map3DProps = { map: GameMap, click: Click, - selectInBox: (box: Box) => void; + selectInBox: (box: Box, shift: boolean) => void; pointerMove: (p: {x: number, y: number}) => void; } @@ -26,7 +26,7 @@ export function Map3D(props: Map3DProps) { const rawClick = (e: ThreeEvent) => { e.stopPropagation(); // turn the 3D position into the 2D map position - props.click({x: e.point.x, y: e.point.z}, e.nativeEvent.button); + props.click({x: e.point.x, y: e.point.z}, e.nativeEvent.button, e.nativeEvent.shiftKey); }; // selection box @@ -44,7 +44,7 @@ export function Map3D(props: Map3DProps) { // TODO - only do select if no action? // maybe send drag up instead of handling it here if (drag && e.nativeEvent.button === 0) { - props.selectInBox({x1: drag.x, y1: drag.y, x2: e.point.x, y2: e.point.z}); + props.selectInBox({x1: drag.x, y1: drag.y, x2: e.point.x, y2: e.point.z}, e.nativeEvent.shiftKey); } setDrag(undefined); setPointer(undefined); diff --git a/packages/client/src/gfx/OrbitControls.js b/packages/client/src/gfx/OrbitControls.js index c991d1d..7d3fba3 100644 --- a/packages/client/src/gfx/OrbitControls.js +++ b/packages/client/src/gfx/OrbitControls.js @@ -916,15 +916,15 @@ class OrbitControls extends EventDispatcher { break; case MOUSE.ROTATE: - - if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + // for RTS - shift/ctrl click is used for selection + if (event.metaKey) { if ( scope.enablePan === false ) return; handleMouseDownPan( event ); state = STATE.PAN; - + } else { if ( scope.enableRotate === false ) return; diff --git a/packages/client/src/gfx/Unit3D.tsx b/packages/client/src/gfx/Unit3D.tsx index 216b8b7..f2485d0 100644 --- a/packages/client/src/gfx/Unit3D.tsx +++ b/packages/client/src/gfx/Unit3D.tsx @@ -41,7 +41,7 @@ function ConeIndicator(props: {unit: UnitState, smoothing: boolean}) { type Unit3DProps = { unit: UnitState, selected: boolean, - click?: (id: UnitId, button: number) => void, + click?: (id: UnitId, button: number, shift: boolean) => void, enemy: boolean, } export function Unit3D(props: Unit3DProps) { @@ -52,7 +52,7 @@ export function Unit3D(props: Unit3DProps) { e.stopPropagation(); if (props.click) - props.click(props.unit.id, e.nativeEvent.button); + props.click(props.unit.id, e.nativeEvent.button, e.nativeEvent.shiftKey); } // TODO better color choices diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index b1b5f91..56da326 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -3,5 +3,19 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] + plugins: [react()], + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes("node_modules")) { + if (id.includes("three")) { + return "vendor_three"; + } + return "vendor"; + } + } + }, + }, + }, }) diff --git a/packages/server/game.ts b/packages/server/game.ts index d6d44d4..421b391 100644 --- a/packages/server/game.ts +++ b/packages/server/game.ts @@ -1,7 +1,7 @@ import { Milliseconds, GameMap, Game, PlayerIndex, Unit, UnitId, Component, CommandPacket, UpdatePacket, Position, TilePos, UnitState, - Hp, Mover, Attacker, Harvester, ProductionFacility, Builder, + Hp, Mover, Attacker, Harvester, ProductionFacility, Builder, Vision, Action, ActionFollow, ActionAttack, PlayerState, } from './types'; @@ -185,6 +185,10 @@ const getBuilderComponent = (unit: Unit) => { return unit.components.find(c => c.type === 'Builder') as Builder; } +const getVisionComponent = (unit: Unit) => { + return unit.components.find(c => c.type === 'Vision') as Vision; +} + function updateUnits(dt: Milliseconds, g: Game) { // Build a unit presence map const presence: PresenceMap = new Map(); @@ -208,11 +212,21 @@ function updateUnits(dt: Milliseconds, g: Game) { }); } -function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap) { - // if no actions are queued, the unit is considered idle - if (unit.actionQueue.length === 0) - return; +function directionTo(a: Position, b: Position) { + return Math.atan2(b.y-a.y, b.x-a.x); +} + +function vecSet(a: Position, b: Position) { + a.x = b.x; + a.y = b.y; +} + +function vecAdd(a: Position, b: Position) { + a.x += b.x; + a.y += b.y; +} +function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap) { const stopMoving = () => { unit.pathToNext = undefined; } @@ -252,7 +266,7 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap // can reach next path setp if (dst < distanceLeft) { // set the unit to the reached path step - unit.position = nextPathStep; + vecSet(unit.position, nextPathStep); // subtract from distance "budget" distanceLeft -= dst; // pop the current path step off @@ -278,9 +292,9 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap const desiredVelocity = {x: dx * distancePerTick, y: dy * distancePerTick }; // TODO - slow starts and braking - unit.velocity = checkMovePossibility(unit, unit.position, desiredVelocity, g.board.map, presence); + vecSet(unit.velocity, checkMovePossibility(unit, unit.position, desiredVelocity, g.board.map, presence)); - unit.position = sum(unit.position, unit.velocity); + vecAdd(unit.position, unit.velocity); return false; } } @@ -291,7 +305,7 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap const findUnitPosition = (targetId: UnitId) => { const target = g.units.find(u => u.id === targetId); // TODO Map if (target) - return target.position; + return { x: target.position.x, y: target.position.y }; else return; } @@ -334,17 +348,139 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap return 'Moving'; } + const findClosestUnitBy = (p: (u: Unit) => boolean) => { + const units = g.units.filter(p); + + if (units.length === 0) { + return; + } + + units.sort((ba, bb) => { + return distance(unit.position, ba.position) - distance(unit.position, bb.position); + }); + + return units[0]; + } + + const detectNearbyEnemy = () => { + const vision = getVisionComponent(unit); + if (!vision) { + return; + } + + // TODO query range for optimizations + const target = findClosestUnitBy(u => + u.owner !== unit.owner && + u.owner !== 0 + ); + if (!target) + return; + + if (distance(unit.position, target.position) > vision.range) { + return; + } + + return target; + } + + const attemptDamage = (ac: Attacker, target: Unit) => { + if (ac.cooldown === 0) { + // TODO - attack cooldown + const hp = getHpComponent(target); + if (hp) { + hp.hp -= ac.damage; + } + ac.cooldown = ac.attackRate; + } + } + + const aggro = (ac: Attacker, target: Unit) => { + // if out of range, just move to target + if (distance(unit.position, target.position) > ac.range) { + // Right now the attack command is upheld even if the unit can't move + // SC in that case just cancels the attack command - TODO decide + moveTowards(target.position, ac.range); + } else { + unit.direction = directionTo(unit.position, target.position); + attemptDamage(ac, target); + } + } + + // Update passive cooldowns + { + const ac = getAttackerComponent(unit); + if (ac) { + ac.cooldown -= dt; + if (ac.cooldown < 0) + ac.cooldown = 0; + } + } + + // Idle state + if (unit.actionQueue.length === 0) { + const ac = getAttackerComponent(unit); + + // TODO run away when attacked + if (!ac) { + return; + } + + const target = detectNearbyEnemy(); + if (!target) { + return; + } + + // TODO - aggro state should depend on the initial aggro location + // stop location needs to be stored somewhere + aggro(ac, target); + + return; + } + const cmd = unit.actionQueue[0]; const owner = g.players[unit.owner - 1]; // TODO players 0-indexed is a bad idea switch (cmd.typ) { case 'Move': { - if (moveTowards(cmd.target, 0.1) !== 'Moving') { + if (moveTowards(cmd.target, 0.2) !== 'Moving') { clearCurrentAction(); } break; } + case 'AttackMove': { + const ac = getAttackerComponent(unit); + // TODO just execute move to go together with formation + if (!ac) { + return; + } + + const closestTarget = detectNearbyEnemy(); + if (closestTarget) { + const MAX_PATH_DEVIATION = 5; + + // TODO compute + const pathDeviation = 0; //computePathDeviation(unit); + if (pathDeviation > MAX_PATH_DEVIATION) { + // lose aggro + // TODO: aggro hysteresis? + // just move + if (moveTowards(cmd.target, 0.2) !== 'Moving') { + clearCurrentAction(); + } + } else { + aggro(ac, closestTarget); + } + } else { + // just move + if (moveTowards(cmd.target, 0.2) !== 'Moving') { + clearCurrentAction(); + } + } + + break; + } + case 'Stop': { stopMoving(); // TODO dedicated cancel action @@ -385,17 +521,7 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap break; } - // if out of range, just move to target - if (distance(unit.position, target.position) > ac.range) { - // Right now the attack command is upheld even if the unit can't move - // SC in that case just cancels the attack command - TODO decide - moveTowards(target.position, ac.range); - } else { - const hp = getHpComponent(target); - if (hp) { - hp.hp -= ac.damage; - } - } + aggro(ac, target); break; } @@ -415,34 +541,34 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap if (!hc.resourcesCarried) { const HARVESTING_DISTANCE = 2; + const HARVESTING_RESOURCE_COUNT = 8; + switch(moveTowards(target.position, HARVESTING_DISTANCE)) { case 'Unreachable': clearCurrentAction(); break; case 'ReachedTarget': - // TODO - harvesting time - hc.resourcesCarried = 50; + if (hc.harvestingProgress >= hc.harvestingTime) { + hc.resourcesCarried = HARVESTING_RESOURCE_COUNT; + // TODO - reset harvesting at any other action + // maybe i could use some "exit state function"? + hc.harvestingProgress = 0; + } else { + hc.harvestingProgress += dt; + } break; } } else { const DROPOFF_DISTANCE = 2; // TODO cache the dropoff base - // TODO - resource dropoff component - const bases = g.units.filter(u => - u.owner == unit.owner && + const target = findClosestUnitBy(u => + u.owner === unit.owner && u.kind === 'Base' ); - if (bases.length === 0) { - // no base to dropoff to + if (!target) break; - } - - bases.sort((ba, bb) => { - return distance(unit.position, ba.position) - distance(unit.position, bb.position); - }); - const target = bases[0]; switch(moveTowards(target.position, DROPOFF_DISTANCE)) { case 'Unreachable': @@ -450,7 +576,6 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap clearCurrentAction(); break; case 'ReachedTarget': - // TODO - harvesting time owner.resources += hc.resourcesCarried; hc.resourcesCarried = undefined; break; @@ -559,12 +684,6 @@ function updateUnit(dt: Milliseconds, g: Game, unit: Unit, presence: PresenceMap } break; } - - default: { - console.warn(`[game] action of type ${cmd.typ} ignored because of no handler`); - clearCurrentAction(); - break; - } } } diff --git a/packages/server/index.ts b/packages/server/index.ts index 92a48bc..c019146 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -23,10 +23,16 @@ type PlayerEntry = { channel?: ServerChannel, } +type SpectatorEntry = { + user: UserId, + channel: ServerChannel, +} + type Match = { game: Game, matchId: string, players: PlayerEntry[], + spectators: SpectatorEntry[], } const app = express() @@ -76,7 +82,7 @@ app.post('/create', async (req, res) => { const map = await getMap('assets/map.png'); const game = newGame(map); const matchId = String(++lastMatchId); // TODO - matches.push({ game, matchId, players: [] }); + matches.push({ game, matchId, players: [], spectators: [] }); const TICK_MS = 50; setInterval(() => { @@ -93,6 +99,10 @@ app.post('/create', async (req, res) => { p.channel.emit('tick', updatePackets[i]); }); + + match.spectators.forEach((s, i) => + s.channel.emit('tick', updatePackets[0]) + ); // io.room(matchId).emit('tick', updatePackets[0]); }, TICK_MS); @@ -183,20 +193,47 @@ io.onConnection(channel => { console.log(`${channel.id} got disconnected`) }) + channel.on('spectate', (data: Data) => { + const packet = data as IdentificationPacket; + + const m = matches.find(m => m.matchId === packet.matchId); + if (!m) { + console.warn("Received a spectate request to a match that doesn't exist"); + channel.emit('spectate failure', packet.matchId, {reliable: true}); + return; + } + + const spectatorEntry = { + channel, + user: packet.userId, + } + + m.spectators.push(spectatorEntry); + + channel.userData = { + matchId: packet.matchId + }; + + channel.join(String(packet.matchId)); + channel.emit('spectating', {matchId: packet.matchId}, {reliable: true}); + + console.log(`[index] Channel of user ${packet.userId} spectating match ${packet.matchId}`); + }); + channel.on('connect', (data: Data) => { // TODO properly validate data format const packet = data as IdentificationPacket; const m = matches.find(m => m.matchId === packet.matchId); if (!m) { - console.warn("Received a connect request to a match that doesn't exist"); + console.warn("[index] Received a connect request to a match that doesn't exist"); channel.emit('connection failure', packet.matchId, {reliable: true}); return; } const playerEntry = m.players.find(p => p.user === packet.userId); if (!playerEntry) { - console.warn(`Received a connect request to a match(${packet.matchId}) that the user(${packet.userId}) hasn't joined`); + console.warn(`[index] Received a connect request to a match(${packet.matchId}) that the user(${packet.userId}) hasn't joined`); channel.emit('connection failure', packet.matchId, {reliable: true}); return; } diff --git a/packages/server/types.ts b/packages/server/types.ts index 76a9f28..2f3815d 100644 --- a/packages/server/types.ts +++ b/packages/server/types.ts @@ -72,7 +72,7 @@ export type UnitState = { id: number, kind: string, status: 'Moving'|'Attacking'|'Harvesting'|'Producing'|'Idle', - position: Position, + readonly position: Position, velocity: Position, // TODO - Position to Vec2 direction: number, owner: number, @@ -81,7 +81,7 @@ export type UnitState = { } // Components -export type Component = Hp | Attacker | Mover | Building | ProductionFacility | Harvester | Resource | Builder; +export type Component = Hp | Attacker | Mover | Building | ProductionFacility | Harvester | Resource | Builder | Vision; export type Hp = { type: 'Hp', maxHp: number, @@ -90,8 +90,9 @@ export type Hp = { export type Attacker = { type: 'Attacker', damage: number, - cooldown: Milliseconds, + attackRate: Milliseconds, range: number, + cooldown: Milliseconds, } export type Mover = { type: 'Mover', @@ -101,6 +102,8 @@ export type Harvester = { type: 'Harvester', harvestingTime: Milliseconds, harvestingValue: number, + // state + harvestingProgress: number, resourcesCarried?: number, } export type Resource = { @@ -144,6 +147,11 @@ export type Builder = { currentlyBuilding?: UnitId, } +export type Vision = { + type: 'Vision', + range: number, +} + // Internal Game stuff export type TilePos = { x: number, y: number } @@ -151,15 +159,15 @@ export type PlayerIndex = number export type UserId = string export type Unit = { - id: number, + readonly id: number, actionQueue: Action[], - kind: string, // TODO should this be in a component - owner: PlayerIndex, - position: Position, + readonly kind: string, // TODO should this be in a component + readonly owner: PlayerIndex, + readonly position: Position, direction: number, - velocity: Position, + readonly velocity: Position, - components: Component[], + readonly components: Component[], pathToNext?: TilePos[], } diff --git a/packages/server/units.ts b/packages/server/units.ts index 828aa5c..e249108 100644 --- a/packages/server/units.ts +++ b/packages/server/units.ts @@ -11,19 +11,21 @@ const UNIT_CATALOG : Catalog = { 'Harvester': () => [ { type: 'Hp', maxHp: 50, hp: 50 }, { type: 'Mover', speed: 10 }, - { type: 'Attacker', damage: 5, cooldown: 1000, range: 2 }, - { type: 'Harvester', harvestingTime: 1000, harvestingValue: 20 }, + { type: 'Attacker', damage: 5, attackRate: 1000, range: 2, cooldown: 0 }, + { type: 'Harvester', harvestingTime: 1000, harvestingValue: 20, harvestingProgress: 0 }, { type: 'Builder', buildingsProduced: [ { buildingType: 'Base', buildTime: 5000, buildCost: 400 }, { buildingType: 'Barracks', buildTime: 5000, buildCost: 150}, ]}, + { type: 'Vision', range: 10 }, ], 'Base': () => [ { type: 'Hp', maxHp: 1000, hp: 1000 }, { type: 'Building' }, { type: 'ProductionFacility', unitsProduced: [ { unitType: 'Harvester', productionTime: 5000, productionCost: 50 } - ]} + ]}, + { type: 'Vision', range: 5 }, ], 'ResourceNode': () => [ { type: 'Resource', value: 100 } @@ -33,12 +35,14 @@ const UNIT_CATALOG : Catalog = { { type: 'Building' }, { type: 'ProductionFacility', unitsProduced: [ {unitType: 'Trooper', productionTime: 5000, productionCost: 50} - ]} + ]}, + { type: 'Vision', range: 5 }, ], 'Trooper': () => [ { type: 'Hp', maxHp: 50, hp: 50 }, { type: 'Mover', speed: 10 }, - { type: 'Attacker', damage: 10, cooldown: 500, range: 6 } + { type: 'Attacker', damage: 10, attackRate: 500, range: 6, cooldown: 0 }, + { type: 'Vision', range: 10 }, ] }; @@ -65,8 +69,10 @@ export function createStartingUnits(): Unit[] { startingUnits.push(createUnit(lastUnitId++, 0, 'ResourceNode', {x:6, y:10})); startingUnits.push(createUnit(lastUnitId++, 0, 'ResourceNode', {x:6, y:14})); - startingUnits.push(createUnit(lastUnitId++, 1, 'Harvester', {x:31, y:25})); startingUnits.push(createUnit(lastUnitId++, 1, 'Base', {x:30, y:10})); + startingUnits.push(createUnit(lastUnitId++, 1, 'Harvester', {x:29, y:25})); + startingUnits.push(createUnit(lastUnitId++, 1, 'Harvester', {x:31, y:25})); + startingUnits.push(createUnit(lastUnitId++, 1, 'Harvester', {x:33, y:25})); // TODO proper starting location placement/orientation // bottom right @@ -75,7 +81,9 @@ export function createStartingUnits(): Unit[] { startingUnits.push(createUnit(lastUnitId++, 0, 'ResourceNode', {x:90, y:80})); startingUnits.push(createUnit(lastUnitId++, 2, 'Base', {x:80, y:85})); + startingUnits.push(createUnit(lastUnitId++, 2, 'Harvester', {x:62, y:90})); startingUnits.push(createUnit(lastUnitId++, 2, 'Harvester', {x:64, y:90})); + startingUnits.push(createUnit(lastUnitId++, 2, 'Harvester', {x:66, y:90})); // left expo startingUnits.push(createUnit(lastUnitId++, 0, 'ResourceNode', {x:6, y:50}));