Implement the socket protocol...mostly
This commit is contained in:
parent
56e0d77c57
commit
7e0ba5431a
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
export default function Board() {}
|
|
26
client/game/BoardSlot.tsx
Normal file
26
client/game/BoardSlot.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type {Card} from './types.ts'
|
||||||
|
import CardToken from './cards/Card.tsx'
|
||||||
|
|
||||||
|
const EMPTY_SPACE = '-'
|
||||||
|
|
||||||
|
interface CardTokenProps {
|
||||||
|
card: Card | null
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardSlot(props: CardTokenProps): JSX.Element {
|
||||||
|
const {onSelect, card} = props
|
||||||
|
|
||||||
|
if (card == null) {
|
||||||
|
return (
|
||||||
|
<button>
|
||||||
|
<span>{EMPTY_SPACE}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CardToken onSelect={onSelect} cardKey={card.id} />
|
||||||
|
)
|
||||||
|
}
|
@ -1,51 +1,103 @@
|
|||||||
import React, { useEffect } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import useSocket from './useSocket.ts'
|
import type { GameHandle, Selection } from './types.ts'
|
||||||
import {CardInstance} from './types.ts'
|
import BoardSlot from './BoardSlot.tsx'
|
||||||
|
import CardToken from './cards/Card.tsx'
|
||||||
|
import {isAttackSelection, isMoveSelection, isPlayCardSelection} from './selection.tsx'
|
||||||
|
|
||||||
interface GameActionsContextValue {}
|
type GameProps = GameHandle
|
||||||
|
|
||||||
const GameActionsContext = React.createContext<GameActionsContextValue | null>(null)
|
export default function Game({
|
||||||
|
team,
|
||||||
|
board,
|
||||||
|
player,
|
||||||
|
deck,
|
||||||
|
enemyLife,
|
||||||
|
enemyDeckSize,
|
||||||
|
enemyHandSize,
|
||||||
|
currentTurn,
|
||||||
|
canDraw,
|
||||||
|
hasDrawn,
|
||||||
|
gameStatus,
|
||||||
|
startTurn,
|
||||||
|
endTurn,
|
||||||
|
startDraw,
|
||||||
|
commitDraw,
|
||||||
|
attackCard,
|
||||||
|
moveCard,
|
||||||
|
playCard,
|
||||||
|
}: GameProps): JSX.Element {
|
||||||
|
const [selection, setSelection] = React.useState<Selection | null>(null)
|
||||||
|
|
||||||
interface GameClientState {
|
function selectCard(nextSelection: Selection): void {
|
||||||
player_id: string
|
if (!selection) {
|
||||||
match_id: string
|
setSelection(nextSelection)
|
||||||
result: string
|
} else {
|
||||||
}
|
if (isAttackSelection(selection, nextSelection)) {
|
||||||
|
attackCard(selection.index, nextSelection.index)
|
||||||
|
} else if (isMoveSelection(selection, nextSelection)) {
|
||||||
|
moveCard(selection.index, nextSelection.index)
|
||||||
|
} else if (isPlayCardSelection(selection, nextSelection)) {
|
||||||
|
playCard(selection.index, nextSelection.index)
|
||||||
|
}
|
||||||
|
setSelection(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface BoardPosition {
|
function selectTurnButton(): void {
|
||||||
card: CardInstance | null
|
}
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayerBoard {
|
const enemyBoard = team === 1 ? board.scourge : board.sentinal
|
||||||
0: BoardPosition
|
const allyBoard = team === 1 ? board.sentinal : board.scourge
|
||||||
1: BoardPosition
|
|
||||||
2: BoardPosition
|
|
||||||
3: BoardPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
self_board: PlayerBoard
|
|
||||||
self_hand: CardInstance[]
|
|
||||||
enemy_board: PlayerBoard
|
|
||||||
enemy_hand: CardInstance[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameProps {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Game(props: GameProps): JSX.Element {
|
|
||||||
const [state, setState] = React.useState()
|
|
||||||
|
|
||||||
// ensure this is stable wrt state so that onMessage does not have to be constantly reattached
|
|
||||||
// const onMessage = React.useCallback(() => {}, [])
|
|
||||||
|
|
||||||
// const handle = useSocket()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameActionsContext.Provider value={{}}>
|
<div className="game-container">
|
||||||
<div>Hello world!</div>
|
<div className="game-sidebar">
|
||||||
</GameActionsContext.Provider>
|
<div className="player-info">
|
||||||
|
<h4>Opponent</h4>
|
||||||
|
<p>Life: {enemyLife}</p>
|
||||||
|
<p>Deck: {enemyDeckSize}</p>
|
||||||
|
</div>
|
||||||
|
<p>Turn: {currentTurn}</p>
|
||||||
|
{canDraw && <p>Drawing phase...</p>}
|
||||||
|
{hasDrawn && <p>Action phase...</p>}
|
||||||
|
<button onClick={selectTurnButton}>End/Start Turn</button>
|
||||||
|
<div className="player-info">
|
||||||
|
<p>Life: {player.life}</p>
|
||||||
|
<p>Deck: {deck.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="game-board">
|
||||||
|
<div className="hand">
|
||||||
|
{Array(enemyHandSize).fill(null).map(() => (
|
||||||
|
<CardToken cardKey={null} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="fighter-area enemy">
|
||||||
|
{enemyBoard.map((card, index) => (
|
||||||
|
<BoardSlot
|
||||||
|
card={card ?? null}
|
||||||
|
onSelect={() => selectCard({target: 'opponent', type: 'board', index})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="fighter-area ally">
|
||||||
|
{allyBoard.map((card, index) => (
|
||||||
|
<BoardSlot
|
||||||
|
card={card ?? null}
|
||||||
|
onSelect={() => selectCard({target: 'ally', type: 'board', index})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hand">
|
||||||
|
{player.hand.map((card, index) => (
|
||||||
|
<CardToken
|
||||||
|
cardKey={card.id}
|
||||||
|
onSelect={() => selectCard({target: 'ally', type: 'hand', index})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
153
client/game/GameClient.tsx
Normal file
153
client/game/GameClient.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import assertNever from '~/common/assertNever.ts'
|
||||||
|
|
||||||
|
import Loading from '../components/Loading.tsx'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AsyncHandle,
|
||||||
|
FighterArea,
|
||||||
|
GameState,
|
||||||
|
GameAction,
|
||||||
|
GameCommandAPI,
|
||||||
|
} from './types.ts'
|
||||||
|
import useServerSocket from './useServerSocket.ts'
|
||||||
|
import Game from './Game.tsx'
|
||||||
|
|
||||||
|
function reducer(state: GameState, action: GameAction): GameState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'update-state': {
|
||||||
|
return {...action.state, team: state.team}
|
||||||
|
}
|
||||||
|
case 'set-player': {
|
||||||
|
return {...state, team: action.team}
|
||||||
|
}
|
||||||
|
default: return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: GameState = {
|
||||||
|
board: {
|
||||||
|
sentinal: Array(4).fill(undefined) as FighterArea,
|
||||||
|
scourge: Array(4).fill(undefined) as FighterArea,
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
name: '',
|
||||||
|
id: 0,
|
||||||
|
hand: [],
|
||||||
|
life: 0,
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
deck: [],
|
||||||
|
team: 1,
|
||||||
|
enemyLife: 0,
|
||||||
|
enemyDeckSize: 0,
|
||||||
|
enemyHandSize: 0,
|
||||||
|
currentTurn: 1,
|
||||||
|
canDraw: false,
|
||||||
|
hasDrawn: false,
|
||||||
|
gameStatus: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameClient(): JSX.Element {
|
||||||
|
const [state, dispatch] = React.useReducer(reducer, initialState)
|
||||||
|
|
||||||
|
const handleGameUpdate = React.useCallback((data: Record<string, unknown>) => {
|
||||||
|
console.log(data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const socketHandle = useServerSocket(handleGameUpdate)
|
||||||
|
|
||||||
|
const gameHandle = React.useMemo<AsyncHandle<GameCommandAPI>>(
|
||||||
|
() => {
|
||||||
|
if (socketHandle.status !== 'connected') return socketHandle
|
||||||
|
return {
|
||||||
|
status: 'connected',
|
||||||
|
handle: {
|
||||||
|
readyPlayer: () => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 's',
|
||||||
|
cmd: 'b',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
startTurn: () => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 's',
|
||||||
|
cmd: 's',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
endTurn: () => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 's',
|
||||||
|
cmd: 'e',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getView: () => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 's',
|
||||||
|
cmd: 'g',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
startDraw: () => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 'a',
|
||||||
|
cmd: 's',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
commitDraw: (cardIndex: number) => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 'a',
|
||||||
|
cmd: `d ${cardIndex}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
playCard: (handIndex: number, positionIndex: number) => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 'a',
|
||||||
|
cmd: `p ${handIndex} ${positionIndex}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
moveCard: (positionFrom: number, positionTo: number) => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 'a',
|
||||||
|
cmd: `m ${positionFrom} ${positionTo}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
attackCard: (positionFrom: number, positionTo: number) => {
|
||||||
|
if (socketHandle.status !== 'connected') return
|
||||||
|
socketHandle.handle.sendGameCommand({
|
||||||
|
type: 'a',
|
||||||
|
cmd: `a ${positionFrom} ${positionTo}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[socketHandle, state],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (gameHandle.status !== 'connected') return
|
||||||
|
gameHandle.handle.readyPlayer()
|
||||||
|
if (state?.team === 1) gameHandle.handle.startTurn()
|
||||||
|
}, [gameHandle, state?.team])
|
||||||
|
|
||||||
|
switch (gameHandle.status) {
|
||||||
|
case 'connected': {
|
||||||
|
if (!state) return <Loading />
|
||||||
|
return <Game {...state} {...gameHandle.handle} />
|
||||||
|
}
|
||||||
|
case 'not-connected':
|
||||||
|
case 'connecting': {
|
||||||
|
return <Loading />
|
||||||
|
}
|
||||||
|
default: return assertNever(gameHandle)
|
||||||
|
}
|
||||||
|
}
|
@ -1,28 +1,32 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import {CardKey, getCardSrc} from './cards.ts'
|
import type {CardKey} from '../types.ts'
|
||||||
|
|
||||||
|
import {getCardAlt, getCardSrc} from './cards.ts'
|
||||||
|
|
||||||
const EMPTY_SPACE = '-'
|
const EMPTY_SPACE = '-'
|
||||||
|
|
||||||
interface CardTokenProps {
|
interface CardTokenProps {
|
||||||
cardKey: CardKey | null
|
cardKey: CardKey | null
|
||||||
onClick?: () => void
|
onSelect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CardToken(props: CardTokenProps): JSX.Element {
|
export default function CardToken(props: CardTokenProps): JSX.Element {
|
||||||
const {onClick, cardKey} = props
|
const {onSelect, cardKey} = props
|
||||||
|
|
||||||
if (cardKey == null) {
|
if (cardKey == null) {
|
||||||
return (
|
return (
|
||||||
<button>
|
<div>
|
||||||
<span>{EMPTY_SPACE}</span>
|
<span>{EMPTY_SPACE}</span>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const card = getCardSrc(cardKey)
|
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick}>
|
<button onClick={onSelect}>
|
||||||
<img src={card} alt={cardKey} />
|
<img
|
||||||
|
src={getCardSrc(cardKey)}
|
||||||
|
alt={getCardAlt(cardKey)}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
import {CardInstance} from '../types.ts'
|
|
||||||
|
|
||||||
import Card from './Card.tsx'
|
|
||||||
|
|
||||||
interface HandProps {
|
|
||||||
cards: CardInstance[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Hand(props: HandProps): JSX.Element {
|
|
||||||
const {cards} = props
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{cards.map((card) => <Card cardKey={card.key} />)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,112 +1,21 @@
|
|||||||
export type CardKey =
|
import type {CardKey} from '../types.ts'
|
||||||
| 'cl_2'
|
|
||||||
| 'cl_3'
|
|
||||||
| 'cl_4'
|
|
||||||
| 'cl_5'
|
|
||||||
| 'cl_6'
|
|
||||||
| 'cl_7'
|
|
||||||
| 'cl_8'
|
|
||||||
// | 'cl_9'
|
|
||||||
// | 'cl_10'
|
|
||||||
// | 'cl_11'
|
|
||||||
// | 'cl_12'
|
|
||||||
// | 'cl_13'
|
|
||||||
| 'cl_14'
|
|
||||||
| 'di_2'
|
|
||||||
| 'di_3'
|
|
||||||
| 'di_4'
|
|
||||||
| 'di_5'
|
|
||||||
| 'di_6'
|
|
||||||
| 'di_7'
|
|
||||||
| 'di_8'
|
|
||||||
// | 'di_9'
|
|
||||||
// | 'di_10'
|
|
||||||
// | 'di_11'
|
|
||||||
// | 'di_12'
|
|
||||||
// | 'di_13'
|
|
||||||
| 'di_14'
|
|
||||||
// | 'hr_2'
|
|
||||||
// | 'hr_3'
|
|
||||||
// | 'hr_4'
|
|
||||||
// | 'hr_5'
|
|
||||||
// | 'hr_6'
|
|
||||||
// | 'hr_7'
|
|
||||||
// | 'hr_8'
|
|
||||||
// | 'hr_9'
|
|
||||||
// | 'hr_10'
|
|
||||||
// | 'hr_11'
|
|
||||||
// | 'hr_12'
|
|
||||||
// | 'hr_13'
|
|
||||||
// | 'hr_14'
|
|
||||||
| 'sp_2'
|
|
||||||
| 'sp_3'
|
|
||||||
| 'sp_4'
|
|
||||||
| 'sp_5'
|
|
||||||
| 'sp_6'
|
|
||||||
| 'sp_7'
|
|
||||||
| 'sp_8'
|
|
||||||
// | 'sp_9'
|
|
||||||
// | 'sp_10'
|
|
||||||
// | 'sp_11'
|
|
||||||
// | 'sp_12'
|
|
||||||
// | 'sp_13'
|
|
||||||
| 'sp_14'
|
|
||||||
|
|
||||||
const cardPaths: Record<CardKey, string> = {
|
const cardPaths: Record<CardKey, string> = {
|
||||||
cl_2: 'cl_2.png',
|
0: 'joker.png',
|
||||||
cl_3: 'cl_3.png',
|
1: 'sp_2.png',
|
||||||
cl_4: 'cl_4.png',
|
2: 'sp_14.png',
|
||||||
cl_5: 'cl_5.png',
|
3: 'sp_3.png',
|
||||||
cl_6: 'cl_6.png',
|
4: 'sp_4.png',
|
||||||
cl_7: 'cl_7.png',
|
5: 'sp_5.png',
|
||||||
cl_8: 'cl_8.png',
|
6: 'sp_6.png',
|
||||||
// cl_9: 'cl_9.png',
|
7: 'sp_7.png',
|
||||||
// cl_10: 'cl_10.png',
|
8: 'sp_8.png',
|
||||||
// cl_11: 'cl_11.png',
|
|
||||||
// cl_12: 'cl_12.png',
|
|
||||||
// cl_13: 'cl_13.png',
|
|
||||||
cl_14: 'cl_14.png',
|
|
||||||
di_2: 'di_2.png',
|
|
||||||
di_3: 'di_3.png',
|
|
||||||
di_4: 'di_4.png',
|
|
||||||
di_5: 'di_5.png',
|
|
||||||
di_6: 'di_6.png',
|
|
||||||
di_7: 'di_7.png',
|
|
||||||
di_8: 'di_8.png',
|
|
||||||
// di_9: 'di_9.png',
|
|
||||||
// di_10: 'di_10.png',
|
|
||||||
// di_11: 'di_11.png',
|
|
||||||
// di_12: 'di_12.png',
|
|
||||||
// di_13: 'di_13.png',
|
|
||||||
di_14: 'di_14.png',
|
|
||||||
// hr_2: 'hr_2.png',
|
|
||||||
// hr_3: 'hr_3.png',
|
|
||||||
// hr_4: 'hr_4.png',
|
|
||||||
// hr_5: 'hr_5.png',
|
|
||||||
// hr_6: 'hr_6.png',
|
|
||||||
// hr_7: 'hr_7.png',
|
|
||||||
// hr_8: 'hr_8.png',
|
|
||||||
// hr_9: 'hr_9.png',
|
|
||||||
// hr_10: 'hr_10.png',
|
|
||||||
// hr_11: 'hr_11.png',
|
|
||||||
// hr_12: 'hr_12.png',
|
|
||||||
// hr_13: 'hr_13.png',
|
|
||||||
// hr_14: 'hr_14.png',
|
|
||||||
sp_2: 'sp_2.png',
|
|
||||||
sp_3: 'sp_3.png',
|
|
||||||
sp_4: 'sp_4.png',
|
|
||||||
sp_5: 'sp_5.png',
|
|
||||||
sp_6: 'sp_6.png',
|
|
||||||
sp_7: 'sp_7.png',
|
|
||||||
sp_8: 'sp_8.png',
|
|
||||||
// sp_9: 'sp_9.png',
|
|
||||||
// sp_10: 'sp_10.png',
|
|
||||||
// sp_11: 'sp_11.png',
|
|
||||||
// sp_12: 'sp_12.png',
|
|
||||||
// sp_13: 'sp_13.png',
|
|
||||||
sp_14: 'sp_14.png',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCardSrc(cardKey: CardKey): string {
|
export function getCardSrc(cardKey: CardKey): string {
|
||||||
return `/assets/${cardPaths[cardKey]}`
|
return `/assets/${cardPaths[cardKey]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCardAlt(cardKey: CardKey): string {
|
||||||
|
return cardPaths[cardKey]
|
||||||
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import {CardKey} from './cards.ts'
|
|
||||||
|
|
||||||
export default function getCardKey(suit: string, value: string): CardKey {
|
|
||||||
return `${suit}_${value}` as CardKey
|
|
||||||
}
|
|
31
client/game/selection.tsx
Normal file
31
client/game/selection.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type {Selection} from './types.ts'
|
||||||
|
|
||||||
|
function isAlly(selection: Selection): boolean {
|
||||||
|
return selection.target === 'ally'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHand(selection: Selection): boolean {
|
||||||
|
return selection.type === 'hand'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoard(selection: Selection): boolean {
|
||||||
|
return selection.type === 'board'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlayCardSelection(first: Selection, second: Selection): boolean {
|
||||||
|
const isMyHand = isAlly(first) && isHand(first)
|
||||||
|
const targetsMyBoard = isAlly(second) && isBoard(second)
|
||||||
|
return isMyHand && targetsMyBoard
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttackSelection(first: Selection, second: Selection): boolean {
|
||||||
|
const isMyBoard = isAlly(first) && isBoard(first)
|
||||||
|
const targetsEnemyBoard = !isAlly(second) && isBoard(second)
|
||||||
|
return isMyBoard && targetsEnemyBoard
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMoveSelection(first: Selection, second: Selection): boolean {
|
||||||
|
const isMyBoard = isAlly(first) && isBoard(first)
|
||||||
|
const isMyOtherBoard = isAlly(second) && isBoard(second) && first.index !== second.index
|
||||||
|
return isMyBoard && isMyOtherBoard
|
||||||
|
}
|
@ -1,6 +1,121 @@
|
|||||||
import {CardKey} from './cards/cards'
|
|
||||||
|
|
||||||
export interface CardInstance {
|
export interface CardInstance {
|
||||||
key: CardKey
|
key: CardKey
|
||||||
// other relevant modifiers to show user
|
// other relevant modifiers to show user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AsyncHandle<T> =
|
||||||
|
| { status: 'not-connected' }
|
||||||
|
| { status: 'connecting' }
|
||||||
|
| { status: 'connected'; handle: T}
|
||||||
|
|
||||||
|
export type CardKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||||
|
|
||||||
|
export interface Card {
|
||||||
|
type: number
|
||||||
|
basePower: number
|
||||||
|
power: number
|
||||||
|
id: CardKey
|
||||||
|
counters: number
|
||||||
|
owner: number
|
||||||
|
position: number
|
||||||
|
effects: {id: number}[]
|
||||||
|
sick: boolean
|
||||||
|
spell: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FighterSlot = Card | undefined
|
||||||
|
export type FighterArea = [FighterSlot, FighterSlot, FighterSlot, FighterSlot]
|
||||||
|
export interface Board {
|
||||||
|
sentinal: FighterArea
|
||||||
|
scourge: FighterArea
|
||||||
|
}
|
||||||
|
export interface Player {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
hand: Card[]
|
||||||
|
life: number
|
||||||
|
ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamEnumMap {
|
||||||
|
1: 'sentinal'
|
||||||
|
2: 'scourge'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameStatusMap {
|
||||||
|
0: 'lobby'
|
||||||
|
1: 'ready'
|
||||||
|
2: 'playing'
|
||||||
|
3: 'stop'
|
||||||
|
4: 'sentinal-win'
|
||||||
|
5: 'scourage-win'
|
||||||
|
6: 'draw'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
board: Board
|
||||||
|
player: Player
|
||||||
|
deck: Card[]
|
||||||
|
team: 1 | 2
|
||||||
|
enemyLife: number
|
||||||
|
enemyDeckSize: number
|
||||||
|
enemyHandSize: number
|
||||||
|
currentTurn: number
|
||||||
|
canDraw: boolean
|
||||||
|
hasDrawn: boolean
|
||||||
|
gameStatus: keyof GameStatusMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameAction =
|
||||||
|
| { type: 'set-player'; team: 1 | 2}
|
||||||
|
| { type: 'update-state'; state: Omit<GameState, 'team'>}
|
||||||
|
|
||||||
|
export interface GameCommandAPI {
|
||||||
|
readyPlayer: () => void
|
||||||
|
startTurn: () => void
|
||||||
|
endTurn: () => void
|
||||||
|
getView: () => void
|
||||||
|
startDraw: () => void
|
||||||
|
commitDraw: (cardIndex: number) => void
|
||||||
|
playCard: (handIndex: number, positionIndex: number) => void
|
||||||
|
moveCard: (positionFrom: number, positionTo: number) => void
|
||||||
|
attackCard: (positionFrom: number, positionTo: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameHandle = GameState & GameCommandAPI
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
export type GameCommandEnum = 'a' | 's'
|
||||||
|
|
||||||
|
export interface CommandVariantMap {
|
||||||
|
// "state" commands
|
||||||
|
s: 'b' | 's' | 'e' | 'g'
|
||||||
|
// "action" commands
|
||||||
|
a: 's' | 'd' | 'p' | 'm' | 'a'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandVariantParamMap {
|
||||||
|
s: Record<string, never>
|
||||||
|
a: {
|
||||||
|
s: never
|
||||||
|
d: number
|
||||||
|
p: [number, number]
|
||||||
|
m: [number, number]
|
||||||
|
a: [number, number]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameCommand {
|
||||||
|
playerId: string
|
||||||
|
type: GameCommandEnum,
|
||||||
|
cmd: string // "<Variant> <VariantParam1> <VariantParam2> ..."
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionTarget = 'ally' | 'opponent'
|
||||||
|
type SelectionType = 'hand' | 'board'
|
||||||
|
|
||||||
|
export interface Selection {
|
||||||
|
target: SelectionTarget
|
||||||
|
type: SelectionType
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
189
client/game/useServerSocket.ts
Normal file
189
client/game/useServerSocket.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import useWebSocket from 'react-use-websocket'
|
||||||
|
|
||||||
|
import assertNever from '~/common/assertNever.ts'
|
||||||
|
|
||||||
|
import { useUser } from '../user.tsx'
|
||||||
|
|
||||||
|
import { AsyncHandle, GameCommand, GameAction } from './types.ts'
|
||||||
|
|
||||||
|
const MY_ID = (function(){
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
function shouldReconnect() {
|
||||||
|
console.log('Reconnecting...')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// session commands
|
||||||
|
// query, join, leave, play (game command), poll
|
||||||
|
interface SessionCommandAPI {
|
||||||
|
query: () => void
|
||||||
|
join: () => void
|
||||||
|
leave: () => void
|
||||||
|
play: () => void
|
||||||
|
poll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocketMessage {
|
||||||
|
command: keyof SessionCommandAPI
|
||||||
|
// required unless command === 'query'
|
||||||
|
match_id?: string
|
||||||
|
player_id?: string
|
||||||
|
// only present if command === 'play'
|
||||||
|
game_command?: GameCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameSocketSessionState =
|
||||||
|
| { status: 'not-connected' }
|
||||||
|
| { status: 'connecting' }
|
||||||
|
| { status: 'finding-game' }
|
||||||
|
| { status: 'joining-game'; playerId: string; matchId: string }
|
||||||
|
| { status: 'in-game'; playerId: string; matchId: string }
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'open' }
|
||||||
|
| { type: 'close' }
|
||||||
|
| { type: 'find-game'; playerId: string; matchId: string }
|
||||||
|
| { type: 'join-game' }
|
||||||
|
|
||||||
|
function reducer(state: GameSocketSessionState, action: Action): GameSocketSessionState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'open': {
|
||||||
|
if (state.status !== 'connecting') return state
|
||||||
|
return { status: 'finding-game' }
|
||||||
|
}
|
||||||
|
case 'find-game': {
|
||||||
|
if (state.status !== 'finding-game') return state
|
||||||
|
return { status: 'joining-game', matchId: action.matchId, playerId: action.playerId }
|
||||||
|
}
|
||||||
|
case 'join-game': {
|
||||||
|
if (state.status !== 'joining-game') return state
|
||||||
|
return { ...state, status: 'in-game' }
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
return { status: 'not-connected' }
|
||||||
|
}
|
||||||
|
default: return assertNever(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: GameSocketSessionState = {status: 'connecting'}
|
||||||
|
|
||||||
|
interface SocketHandle {
|
||||||
|
sendGameCommand: (command: Omit<GameCommand, 'playerId'>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useServerSocket(
|
||||||
|
onUpdate: (action: GameAction) => void
|
||||||
|
): AsyncHandle<SocketHandle> {
|
||||||
|
const profile = useUser()
|
||||||
|
const [state, dispatch] = React.useReducer(reducer, initialState)
|
||||||
|
|
||||||
|
// prep socket
|
||||||
|
|
||||||
|
const onMessage = React.useCallback((message: WebSocketEventMap['message']) => {
|
||||||
|
const data = JSON.parse(message.data)
|
||||||
|
switch (data.result) {
|
||||||
|
case 'found': {
|
||||||
|
dispatch({
|
||||||
|
type: 'find-game',
|
||||||
|
matchId: data.match_id,
|
||||||
|
playerId: data.player_id,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'joined p1': {
|
||||||
|
onUpdate({ type: 'set-player', team: 1 })
|
||||||
|
dispatch ({type: 'join-game'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'joined p2': {
|
||||||
|
onUpdate({ type: 'set-player', team: 2 })
|
||||||
|
dispatch ({type: 'join-game' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
onUpdate(data)
|
||||||
|
}, [onUpdate])
|
||||||
|
|
||||||
|
const onOpen = React.useCallback(() => {
|
||||||
|
console.log('socket opened')
|
||||||
|
dispatch({type: 'open'})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onError = React.useCallback((event: WebSocketEventMap['error']) => {
|
||||||
|
console.error(event)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onClose = React.useCallback((_event: WebSocketEventMap['close']) => {
|
||||||
|
console.log('socket closed')
|
||||||
|
dispatch({type: 'close'})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const url = React.useMemo(
|
||||||
|
() => `ws://localhost:7636/ws`,
|
||||||
|
[profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
const {sendJsonMessage} = useWebSocket(
|
||||||
|
url,
|
||||||
|
{onMessage, onOpen, onError, onClose, shouldReconnect},
|
||||||
|
)
|
||||||
|
|
||||||
|
// convenient type-safe wrapper
|
||||||
|
const sendJson = React.useCallback((message: SocketMessage) => {
|
||||||
|
sendJsonMessage(message)
|
||||||
|
}, [sendJsonMessage])
|
||||||
|
|
||||||
|
const sendGameCommand = React.useCallback((gameCommand: Omit<GameCommand, 'playerId'>) => {
|
||||||
|
if (state.status !== 'in-game') return
|
||||||
|
sendJson({
|
||||||
|
player_id: MY_ID,
|
||||||
|
match_id: state.matchId,
|
||||||
|
command: 'play',
|
||||||
|
game_command: {
|
||||||
|
...gameCommand,
|
||||||
|
playerId: state.playerId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [state, sendJson])
|
||||||
|
|
||||||
|
// effects to push the coordinator along
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.status !== 'finding-game') return
|
||||||
|
sendJson({command: 'query', player_id: MY_ID})
|
||||||
|
}, [sendJson, state.status])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (state.status !== 'joining-game') return
|
||||||
|
sendJson({command: 'join', player_id: state.playerId, match_id: state.matchId})
|
||||||
|
}, [sendJson, state])
|
||||||
|
|
||||||
|
// return game command handler in wrapper
|
||||||
|
|
||||||
|
const handle = React.useMemo<AsyncHandle<SocketHandle>>(() => {
|
||||||
|
switch (state.status) {
|
||||||
|
case 'in-game': {
|
||||||
|
return {status: 'connected', handle: {sendGameCommand}}
|
||||||
|
}
|
||||||
|
case 'finding-game':
|
||||||
|
case 'joining-game': {
|
||||||
|
return {status: 'connecting'}
|
||||||
|
}
|
||||||
|
case 'connecting':
|
||||||
|
case 'not-connected': {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
default: return assertNever(state)
|
||||||
|
}
|
||||||
|
}, [sendGameCommand])
|
||||||
|
|
||||||
|
return handle
|
||||||
|
}
|
@ -1,133 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import useWebSocket from 'react-use-websocket'
|
|
||||||
|
|
||||||
import assertNever from '~/common/assertNever.ts'
|
|
||||||
|
|
||||||
import { useUser } from '../user.tsx'
|
|
||||||
|
|
||||||
function shouldReconnect() {
|
|
||||||
console.log('Reconnecting...')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type AsyncHandle<T> =
|
|
||||||
| { status: 'not-connected' }
|
|
||||||
| { status: 'connecting' }
|
|
||||||
| { status: 'connected'; handle: T}
|
|
||||||
|
|
||||||
interface SessionCommandAPI {
|
|
||||||
query: () => void
|
|
||||||
join: () => void
|
|
||||||
leave: () => void
|
|
||||||
play: () => void
|
|
||||||
poll: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameCommandAPI {
|
|
||||||
act: () => void,
|
|
||||||
getState: () => void,
|
|
||||||
debug: () => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
type GameCommandEnum = 'a' | 's' | 'd'
|
|
||||||
|
|
||||||
interface SocketMessage {
|
|
||||||
playerId?: string
|
|
||||||
matchId?: string
|
|
||||||
command: keyof SessionCommandAPI
|
|
||||||
gameCommand?: {
|
|
||||||
playerId: string
|
|
||||||
type: GameCommandEnum,
|
|
||||||
cmd: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type State =
|
|
||||||
| { status: 'not-connected' }
|
|
||||||
| { status: 'connecting' }
|
|
||||||
| { status: 'finding-game' }
|
|
||||||
| { status: 'in-game'; playerId: string; matchId: string }
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: 'open' }
|
|
||||||
| { type: 'close' }
|
|
||||||
| { type: 'error' }
|
|
||||||
| { type: 'game-found'; matchId: string; playerId: string }
|
|
||||||
|
|
||||||
function reducer(_state: State, action: Action): State {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'open': return { status: 'finding-game' }
|
|
||||||
case 'game-found': return { status: 'in-game', matchId: action.matchId, playerId: action.playerId }
|
|
||||||
case 'close': return { status: 'not-connected' }
|
|
||||||
case 'error': return { status: 'connecting' }
|
|
||||||
default: return assertNever(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: State = {status: 'not-connected'}
|
|
||||||
|
|
||||||
export default function useSocket(): AsyncHandle<GameCommandAPI> {
|
|
||||||
const profile = useUser()
|
|
||||||
const [state, dispatch] = React.useReducer(reducer, initialState)
|
|
||||||
|
|
||||||
const onMessage = React.useCallback((message: WebSocketEventMap['message']) => {
|
|
||||||
const data = JSON.parse(message.data)
|
|
||||||
dispatch({ type: 'update', ...data })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onOpen = React.useCallback(() => {
|
|
||||||
console.log('socket opened')
|
|
||||||
dispatch({type: 'open'})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onError = React.useCallback((event: WebSocketEventMap['error']) => {
|
|
||||||
console.error(event)
|
|
||||||
dispatch({type: 'error'})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onClose = React.useCallback((_event: WebSocketEventMap['close']) => {
|
|
||||||
console.log('socket closed')
|
|
||||||
dispatch({type: 'close'})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const url = React.useMemo(
|
|
||||||
// () => `ws://arcade.saintnet.tech:7636/ws?name=${profile.displayName}`,
|
|
||||||
() => `ws://arcade.saintnet.tech:7636/ws`,
|
|
||||||
[profile],
|
|
||||||
)
|
|
||||||
const socket = useWebSocket(
|
|
||||||
url,
|
|
||||||
{onMessage, onOpen, onError, onClose, shouldReconnect},
|
|
||||||
)
|
|
||||||
|
|
||||||
const sendJson = React.useCallback((message: SocketMessage) => {
|
|
||||||
socket.send(JSON.stringify(message))
|
|
||||||
}, [socket])
|
|
||||||
|
|
||||||
const handle = React.useMemo(() => ({
|
|
||||||
// session commands
|
|
||||||
query: () => {},
|
|
||||||
join: () => {},
|
|
||||||
leave: () => {},
|
|
||||||
play: () => {},
|
|
||||||
poll: () => {},
|
|
||||||
// game commands
|
|
||||||
act: () => {},
|
|
||||||
getState: () => {},
|
|
||||||
debug: () => {},
|
|
||||||
}), [sendJson])
|
|
||||||
|
|
||||||
switch (state.status) {
|
|
||||||
case 'in-game': {
|
|
||||||
return {status: 'connected', handle}
|
|
||||||
}
|
|
||||||
case 'finding-game': {
|
|
||||||
return {status: 'connecting'}
|
|
||||||
}
|
|
||||||
case 'connecting':
|
|
||||||
case 'not-connected':
|
|
||||||
return state
|
|
||||||
default: return assertNever(state)
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,7 +4,7 @@ import assertNever from '~/common/assertNever.ts'
|
|||||||
|
|
||||||
import IntroPage from '../components/IntroPage.tsx'
|
import IntroPage from '../components/IntroPage.tsx'
|
||||||
import Page from '../components/Page.tsx'
|
import Page from '../components/Page.tsx'
|
||||||
import Game from '../game/Game.tsx'
|
import GameClient from '../game/GameClient.tsx'
|
||||||
|
|
||||||
type MenuState = 'menu' | 'play'
|
type MenuState = 'menu' | 'play'
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export default function AppPage() {
|
|||||||
case 'menu': {
|
case 'menu': {
|
||||||
return (
|
return (
|
||||||
<IntroPage>
|
<IntroPage>
|
||||||
<button onClick={() => setMenuState('play')}>
|
<button className="menu-button" onClick={() => setMenuState('play')}>
|
||||||
Play
|
Play
|
||||||
</button>
|
</button>
|
||||||
</IntroPage>
|
</IntroPage>
|
||||||
@ -24,7 +24,7 @@ export default function AppPage() {
|
|||||||
case 'play': {
|
case 'play': {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Game />
|
<GameClient />
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3em;
|
font-size: 3em;
|
||||||
}
|
}
|
||||||
@ -12,8 +16,6 @@ h2 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
font-family: sans-serif;
|
|
||||||
|
|
||||||
background-color: #ad9885;
|
background-color: #ad9885;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +95,17 @@ h2 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
font-size: 2em;
|
||||||
|
height: 2em;
|
||||||
|
width: 4em;
|
||||||
|
|
||||||
|
background-color: #ffe7d1;
|
||||||
|
border: 0.1em solid #b85900;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
box-shadow: -2px 2px 0 rgba(144, 90, 39, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
/* Common pages */
|
/* Common pages */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@ -136,12 +149,23 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-sidebar {
|
.game-sidebar {
|
||||||
flex: 0;
|
flex: 0 0 auto;
|
||||||
width: 200px;
|
width: 10em;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
background: #942911;
|
||||||
|
color: #ddf6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info {
|
||||||
|
margin: 1em;
|
||||||
|
padding: 1em;
|
||||||
|
background-color: #c4c4c4;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-board {
|
.game-board {
|
||||||
|
Loading…
Reference in New Issue
Block a user