parent
f5a9f3fde6
commit
bd714aa1e8
@ -1,38 +1,94 @@ |
||||
.App { |
||||
body { |
||||
text-align: center; |
||||
background-color: #282c34; |
||||
} |
||||
|
||||
.App-logo { |
||||
height: 40vmin; |
||||
pointer-events: none; |
||||
.App, .scrollbox { |
||||
position: absolute; |
||||
top: 0; |
||||
bottom: 0; |
||||
left: 0; |
||||
right: 0; |
||||
} |
||||
|
||||
@media (prefers-reduced-motion: no-preference) { |
||||
.App-logo { |
||||
animation: App-logo-spin infinite 20s linear; |
||||
} |
||||
.scrollbox { |
||||
overflow-x: scroll; |
||||
overflow-y: scroll; |
||||
} |
||||
|
||||
.App-header { |
||||
background-color: #282c34; |
||||
min-height: 100vh; |
||||
.centerbox { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: center; |
||||
font-size: calc(10px + 2vmin); |
||||
color: white; |
||||
align-content: center; |
||||
flex-direction: column; |
||||
min-width: 100%; |
||||
min-height: 100%; |
||||
width: max-content; |
||||
height: max-content; |
||||
box-sizing: border-box; |
||||
} |
||||
.map { |
||||
flex: 1 0 auto; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.App-link { |
||||
color: #61dafb; |
||||
.mapTile { |
||||
stroke: black; |
||||
stroke-width: 2; |
||||
stroke-linejoin: round; |
||||
} |
||||
|
||||
@keyframes App-logo-spin { |
||||
from { |
||||
transform: rotate(0deg); |
||||
.swatch { |
||||
stroke: black; |
||||
stroke-width: 2; |
||||
stroke-linejoin: round; |
||||
} |
||||
|
||||
.swatch.active { |
||||
stroke-width: 4; |
||||
} |
||||
.swatch.selected { |
||||
stroke: white; |
||||
stroke-width: 5; |
||||
z-index: -1; |
||||
} |
||||
.hexColorPicker { |
||||
position: absolute; |
||||
bottom: 1vh; |
||||
left: 1vw; |
||||
height: auto; |
||||
} |
||||
.colorPickerBackground { |
||||
fill: lightslategray; |
||||
} |
||||
.consoleConnector { |
||||
position: absolute; |
||||
text-shadow: white 0 0 5px; |
||||
color: black; |
||||
top: 0; |
||||
right: 0; |
||||
} |
||||
@media screen and (min-width: 1000px) { |
||||
.hexColorPicker { |
||||
width: 30vw; |
||||
} |
||||
to { |
||||
transform: rotate(360deg); |
||||
.centerbox { |
||||
padding-bottom: calc(2vh + (30vw * 126 / 855)); |
||||
} |
||||
} |
||||
@media screen and (min-width: 306px) and (max-width: 1000px) { |
||||
.hexColorPicker { |
||||
width: 300px; |
||||
} |
||||
.centerbox { |
||||
padding-bottom: calc(2vh + (300px * 126 / 855)); |
||||
} |
||||
} |
||||
@media screen and (max-width: 306px) { |
||||
.hexColorPicker { |
||||
width: 98vw; |
||||
} |
||||
.centerbox { |
||||
padding-bottom: calc(2vh + (98vw * 126 / 855)); |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
import {MapAction} from "./MapAction"; |
||||
import {TileAction} from "./TileAction"; |
||||
import {UserAction} from "./UserAction"; |
||||
import {NetworkAction} from "./NetworkAction"; |
||||
|
||||
export type AppAction = MapAction | TileAction | UserAction | NetworkAction; |
@ -0,0 +1,3 @@ |
||||
export interface BaseAction { |
||||
readonly type: string |
||||
} |
@ -0,0 +1,34 @@ |
||||
import {BaseAction} from "./BaseAction"; |
||||
import {StorageCoordinates} from "../state/Coordinates"; |
||||
import {AppAction} from "./AppAction"; |
||||
|
||||
export const CELL_COLOR = "CELL_COLOR" |
||||
/** Sets the given tile to the given color, or erases it entirely. */ |
||||
export interface CellColorAction extends BaseAction { |
||||
readonly type: typeof CELL_COLOR |
||||
/** The staggered (storage) coordinates of the tile to have its color changed */ |
||||
readonly at: StorageCoordinates |
||||
/** A color in #RRGGBBAA format. */ |
||||
readonly color: string |
||||
} |
||||
|
||||
export function isCellColorAction(action: AppAction): action is CellColorAction { |
||||
return action.type === CELL_COLOR |
||||
} |
||||
|
||||
export const CELL_REMOVE = "CELL_REMOVE" |
||||
export interface CellRemoveAction extends BaseAction { |
||||
readonly type: typeof CELL_REMOVE |
||||
/** The staggered (storage) coordinates of the tile to be removed */ |
||||
readonly at: StorageCoordinates |
||||
} |
||||
|
||||
export function isRemoveCellAction(action: AppAction): action is CellRemoveAction { |
||||
return action.type === CELL_REMOVE |
||||
} |
||||
|
||||
export function isCellAction(action: AppAction): action is CellAction { |
||||
return isRemoveCellAction(action) || isCellColorAction(action); |
||||
} |
||||
|
||||
export type CellAction = CellColorAction | CellRemoveAction |
@ -0,0 +1,8 @@ |
||||
import {CellAction, isCellAction} from "./CellAction"; |
||||
import {AppAction} from "./AppAction"; |
||||
|
||||
export function isMapAction(action: AppAction): action is MapAction { |
||||
return isCellAction(action); |
||||
} |
||||
|
||||
export type MapAction = CellAction |
@ -0,0 +1,175 @@ |
||||
import {BaseAction} from "./BaseAction"; |
||||
import {AppAction} from "./AppAction"; |
||||
import {CellColorAction, isCellColorAction} from "./CellAction"; |
||||
import {SyncedState} from "../state/SyncedState"; |
||||
import {isUserActiveColorAction, UserActiveColorAction} from "./UserAction"; |
||||
|
||||
// TODO: Split into ServerAction and ClientAction files
|
||||
|
||||
export const SERVER_HELLO = "SERVER_HELLO" |
||||
/** Sent in response to the client's ClientHelloAction when the client has been accepted. */ |
||||
export interface ServerHelloAction extends BaseAction { |
||||
readonly type: typeof SERVER_HELLO |
||||
/** The protocol version the server is running on. */ |
||||
readonly version: number |
||||
/** The current state of the server as of when the client connected. */ |
||||
readonly state: SyncedState |
||||
} |
||||
export function isServerHelloAction(action: AppAction): action is ServerHelloAction { |
||||
return action.type === SERVER_HELLO |
||||
} |
||||
|
||||
export const SERVER_GOODBYE = "SERVER_GOODBYE" |
||||
/** Synthesized when the server closes the connection. */ |
||||
export interface ServerGoodbyeAction extends BaseAction { |
||||
readonly type: typeof SERVER_GOODBYE |
||||
/** The error code sent with the close message by the server, or -1 if our side crashed.*/ |
||||
readonly code: number |
||||
/** The text of the server's goodbye message, or the exception message if our side crashed. */ |
||||
readonly reason: string |
||||
/** The current time when this close message was received. */ |
||||
readonly currentTime: Date |
||||
} |
||||
export function isServerGoodbyeAction(action: AppAction): action is ServerGoodbyeAction { |
||||
return action.type === SERVER_GOODBYE |
||||
} |
||||
|
||||
export const SERVER_REFRESH = "SERVER_REFRESH" |
||||
/** Sent in response to the client's ClientRefreshAction. */ |
||||
export interface ServerRefreshAction extends BaseAction { |
||||
readonly type: typeof SERVER_REFRESH |
||||
/** The current state of the server as of when the client's refresh request was processed. */ |
||||
readonly state: SyncedState |
||||
} |
||||
export function isServerRefreshAction(action: AppAction): action is ServerRefreshAction { |
||||
return action.type === SERVER_REFRESH |
||||
} |
||||
|
||||
export const SERVER_OK = "SERVER_OK" |
||||
/** Sent in response to the client's ClientNestedAction, if it succeeds and is applied to the server map. */ |
||||
export interface ServerOKAction extends BaseAction { |
||||
readonly type: typeof SERVER_OK |
||||
/** |
||||
* The IDs of the successful actions. |
||||
* These will always be sent in sequential order - the order in which the server received and applied them. |
||||
* This allows the client to apply that set of actions to its server-state copy and then refresh its local copy. |
||||
*/ |
||||
readonly ids: readonly number[] |
||||
} |
||||
export function isServerOKAction(action: AppAction): action is ServerOKAction { |
||||
return action.type === SERVER_OK |
||||
} |
||||
|
||||
export interface ActionFailure { |
||||
readonly id: number |
||||
readonly error: string |
||||
} |
||||
|
||||
export const SERVER_FAILED = "SERVER_FAILED" |
||||
/** Sent in response to the client's ClientNestedAction, if it fails and has not been applied to the server map. */ |
||||
export interface ServerFailedAction extends BaseAction { |
||||
readonly type: typeof SERVER_FAILED |
||||
readonly failures: readonly ActionFailure[] |
||||
} |
||||
export function isServerFailedAction(action: AppAction): action is ServerFailedAction { |
||||
return action.type === SERVER_FAILED |
||||
} |
||||
|
||||
export enum SocketState { |
||||
CONNECTING = "CONNECTING", |
||||
OPEN = "OPEN", |
||||
} |
||||
export const SERVER_SOCKET_STARTUP = "SERVER_SOCKET_STARTUP" |
||||
/** Synthesized when the websocket begins connecting, i.e., enters the Connecting or Open states.. */ |
||||
export interface ServerSocketStartupAction extends BaseAction { |
||||
readonly type: typeof SERVER_SOCKET_STARTUP |
||||
readonly state: SocketState |
||||
} |
||||
export function isServerSocketStartupAction(action: AppAction): action is ServerSocketStartupAction { |
||||
return action.type === SERVER_SOCKET_STARTUP |
||||
} |
||||
|
||||
export const SERVER_SENT = "SERVER_SENT" |
||||
/** Sent by the server when another client has performed an action. Never sent for the client's own actions. */ |
||||
export interface ServerSentAction extends BaseAction { |
||||
readonly type: typeof SERVER_SENT |
||||
readonly actions: readonly SyncableAction[] |
||||
} |
||||
export function isServerSentAction(action: AppAction): action is ServerSentAction { |
||||
return action.type === SERVER_SENT |
||||
} |
||||
|
||||
export type SyncableAction = SendableAction |
||||
export function isSyncableAction(action: AppAction): action is SyncableAction { |
||||
return isSendableAction(action) |
||||
} |
||||
|
||||
export type ServerAction = |
||||
ServerHelloAction | ServerGoodbyeAction | ServerRefreshAction | |
||||
ServerOKAction | ServerFailedAction | |
||||
ServerSocketStartupAction | ServerSentAction |
||||
export function isServerAction(action: AppAction) { |
||||
return isServerHelloAction(action) || isServerGoodbyeAction(action) || isServerRefreshAction(action) |
||||
|| isServerOKAction(action) || isServerFailedAction(action) |
||||
|| isServerSocketStartupAction(action) || isServerSentAction(action) |
||||
} |
||||
|
||||
export const CLIENT_HELLO = "CLIENT_HELLO" |
||||
/** Sent when the client connects. */ |
||||
export interface ClientHelloAction extends BaseAction { |
||||
readonly type: typeof CLIENT_HELLO |
||||
/** The protocol version the client is running on */ |
||||
readonly version: number |
||||
} |
||||
export function isClientHelloAction(action: AppAction): action is ClientHelloAction { |
||||
return action.type === CLIENT_HELLO |
||||
} |
||||
|
||||
export const CLIENT_REFRESH = "CLIENT_REFRESH" |
||||
/** Sent when the user requests a refresh, or if a malformed action comes through. */ |
||||
export interface ClientRefreshAction extends BaseAction { |
||||
readonly type: typeof CLIENT_REFRESH |
||||
} |
||||
export function isClientRefreshAction(action: AppAction): action is ClientRefreshAction { |
||||
return action.type === CLIENT_REFRESH |
||||
} |
||||
|
||||
export const CLIENT_PENDING = "CLIENT_PENDING" |
||||
/** Synthesized when a user action is ready to be sent to the server. */ |
||||
export interface ClientPendingAction extends BaseAction { |
||||
readonly type: typeof CLIENT_PENDING |
||||
readonly pending: SendableAction |
||||
} |
||||
export function isClientPendingAction(action: AppAction): action is ClientPendingAction { |
||||
return action.type === CLIENT_PENDING |
||||
} |
||||
|
||||
export interface SentAction { |
||||
readonly id: number |
||||
readonly action: SendableAction |
||||
} |
||||
|
||||
export const CLIENT_SENT = "CLIENT_SENT" |
||||
/** Sent to the server when the user performs an action. */ |
||||
export interface ClientSentAction extends BaseAction { |
||||
readonly type: typeof CLIENT_SENT |
||||
readonly nested: readonly SentAction[] |
||||
} |
||||
export function isClientSentAction(action: AppAction): action is ClientSentAction { |
||||
return action.type === CLIENT_SENT |
||||
} |
||||
|
||||
export type SendableAction = CellColorAction | UserActiveColorAction |
||||
export function isSendableAction(action: AppAction): action is SendableAction { |
||||
return isCellColorAction(action) || isUserActiveColorAction(action) |
||||
} |
||||
|
||||
export type ClientAction = ClientHelloAction | ClientRefreshAction | ClientPendingAction | ClientSentAction |
||||
export function isClientAction(action: AppAction): action is ClientAction { |
||||
return isClientHelloAction(action) || isClientRefreshAction(action) || isClientPendingAction(action) || isClientSentAction(action) |
||||
} |
||||
|
||||
export type NetworkAction = ServerAction | ClientAction; |
||||
export function isNetworkAction(action: AppAction): action is NetworkAction { |
||||
return isServerAction(action) || isClientAction(action) |
||||
} |
@ -0,0 +1,28 @@ |
||||
import {BaseAction} from "./BaseAction"; |
||||
import {StorageCoordinates} from "../state/Coordinates"; |
||||
import {AppAction} from "./AppAction"; |
||||
|
||||
export const TILE_PAINT = "TILE_PAINT" |
||||
export interface TilePaintAction extends BaseAction { |
||||
readonly type: typeof TILE_PAINT |
||||
readonly at: StorageCoordinates |
||||
} |
||||
|
||||
export function isTilePaintAction(action: AppAction): action is TilePaintAction { |
||||
return action.type === TILE_PAINT |
||||
} |
||||
|
||||
export const TILE_REMOVE = "TILE_REMOVE" |
||||
export interface TileRemoveAction extends BaseAction { |
||||
readonly type: typeof TILE_REMOVE |
||||
readonly at: StorageCoordinates |
||||
} |
||||
export function isTileRemoveAction(action: AppAction): action is TileRemoveAction { |
||||
return action.type === TILE_REMOVE |
||||
} |
||||
|
||||
export function isTileAction(action: AppAction): action is TileAction { |
||||
return isTilePaintAction(action) || isTileRemoveAction(action) |
||||
} |
||||
|
||||
export type TileAction = TilePaintAction | TileRemoveAction |
@ -0,0 +1,18 @@ |
||||
import {BaseAction} from "./BaseAction"; |
||||
import {AppAction} from "./AppAction"; |
||||
|
||||
export const USER_ACTIVE_COLOR = "USER_ACTIVE_COLOR" |
||||
export interface UserActiveColorAction extends BaseAction { |
||||
readonly type: typeof USER_ACTIVE_COLOR |
||||
/** The hex color that the user will begin painting with. */ |
||||
readonly color: string |
||||
} |
||||
export function isUserActiveColorAction(action: AppAction): action is UserActiveColorAction { |
||||
return action.type === USER_ACTIVE_COLOR |
||||
} |
||||
|
||||
export function isUserAction(action: AppAction): action is UserAction { |
||||
return isUserActiveColorAction(action) |
||||
} |
||||
|
||||
export type UserAction = UserActiveColorAction; |
Before Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1,49 @@ |
||||
import {AppAction} from "../actions/AppAction"; |
||||
import {AppState} from "../state/AppState"; |
||||
import {isTileAction} from "../actions/TileAction"; |
||||
import {tileReducer} from "./TileReducer"; |
||||
import {networkReducer} from "./NetworkReducer"; |
||||
import {CLIENT_PENDING, isNetworkAction, isSendableAction} from "../actions/NetworkAction"; |
||||
import {syncedStateReducer} from "./SyncedStateReducer"; |
||||
import {exhaustivenessCheck} from "../util/TypeUtils"; |
||||
import {isMapAction, MapAction} from "../actions/MapAction"; |
||||
import {isUserAction, UserAction} from "../actions/UserAction"; |
||||
import {clientReducer} from "./ClientReducer"; |
||||
|
||||
function appSyncedStateReducer(oldState: AppState, action: MapAction|UserAction): AppState { |
||||
if (oldState.localState === null) { |
||||
return oldState |
||||
} |
||||
const localState = syncedStateReducer(oldState.localState, action) |
||||
if (localState === oldState.localState) { |
||||
return oldState |
||||
} |
||||
if (isSendableAction(action)) { |
||||
return { |
||||
...oldState, |
||||
localState, |
||||
network: clientReducer(oldState.network, { |
||||
type: CLIENT_PENDING, |
||||
pending: action |
||||
}) |
||||
} |
||||
} else { |
||||
return { |
||||
...oldState, |
||||
localState |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function appStateReducer(oldState: AppState, action: AppAction): AppState { |
||||
if (isMapAction(action) || isUserAction(action)) { |
||||
return appSyncedStateReducer(oldState, action) |
||||
} else if (isTileAction(action)) { |
||||
return tileReducer(oldState, action) |
||||
} else if (isNetworkAction(action)) { |
||||
return networkReducer(oldState, action) |
||||
} |
||||
exhaustivenessCheck(action) |
||||
} |
||||
|
||||
export type AppStateReducer = typeof appStateReducer; |
@ -0,0 +1,38 @@ |
||||
import {CLIENT_HELLO, CLIENT_PENDING, CLIENT_REFRESH, CLIENT_SENT, ClientAction} from "../actions/NetworkAction"; |
||||
import {NetworkState, ServerConnectionState} from "../state/NetworkState"; |
||||
|
||||
// TODO: Verify that only one special message exists at a time.
|
||||
export function clientReducer(oldState: NetworkState, action: ClientAction): NetworkState { |
||||
switch (action.type) { |
||||
case CLIENT_HELLO: |
||||
return { |
||||
...oldState, |
||||
specialMessage: action, |
||||
connectionState: ServerConnectionState.AWAITING_HELLO, |
||||
} |
||||
case CLIENT_REFRESH: |
||||
return { |
||||
...oldState, |
||||
specialMessage: action, |
||||
connectionState: ServerConnectionState.AWAITING_REFRESH, |
||||
} |
||||
case CLIENT_PENDING: |
||||
// This happens when an action is successfully applied locally, so we prepare to send it to the server;
|
||||
// we don't have to actually do anything because it was already done by the time we got here.
|
||||
return { |
||||
...oldState, |
||||
pendingActions: [...oldState.pendingActions, action.pending], |
||||
} |
||||
case CLIENT_SENT: |
||||
if (!action.nested.every((innerAction, index) => |
||||
innerAction.id === oldState.nextID + index && innerAction.action === oldState.pendingActions[index])) { |
||||
throw Error("Only the next actions can be sent, and only with the next IDs.") |
||||
} |
||||
return { |
||||
...oldState, |
||||
nextID: oldState.nextID + action.nested.length, |
||||
sentActions: [...oldState.sentActions, ...action.nested], |
||||
pendingActions: oldState.pendingActions.slice(action.nested.length), |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,45 @@ |
||||
import {StorageCoordinates} from "../state/Coordinates"; |
||||
import {areCellsEquivalent, EMPTY_CELL, getCell, HexCell, HexMap} from "../state/HexMap"; |
||||
import {MapAction} from "../actions/MapAction"; |
||||
import {CELL_COLOR, CELL_REMOVE} from "../actions/CellAction"; |
||||
|
||||
export function updateCell(map: HexMap, coords: StorageCoordinates, newData: Partial<HexCell>): HexMap { |
||||
const oldCell = getCell(map, coords) || EMPTY_CELL; |
||||
return replaceCell(map, coords, { |
||||
...oldCell, |
||||
...newData, |
||||
}) |
||||
} |
||||
|
||||
export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, newCell: HexCell|null): HexMap { |
||||
if (areCellsEquivalent(getCell(oldMap, {line, cell}), newCell)) { |
||||
return oldMap |
||||
} |
||||
const oldLines = oldMap.lineCells |
||||
const oldLine = oldLines[line] |
||||
const newLine = [ |
||||
...oldLine.slice(0, cell), |
||||
newCell, |
||||
...oldLine.slice(cell + 1) |
||||
] |
||||
const newLines = [ |
||||
...oldLines.slice(0, line), |
||||
newLine, |
||||
...oldLines.slice(line + 1) |
||||
] |
||||
return { |
||||
...oldMap, |
||||
lineCells: newLines |
||||
} |
||||
} |
||||
|
||||
export function hexMapReducer(oldState: HexMap, action: MapAction): HexMap { |
||||
switch (action.type) { |
||||
case CELL_COLOR: |
||||
return updateCell(oldState, action.at, { color: action.color }) |
||||
case CELL_REMOVE: |
||||
return replaceCell(oldState, action.at, null) |
||||
} |
||||
} |
||||
|
||||
export type HexMapReducer = typeof hexMapReducer; |
@ -0,0 +1,20 @@ |
||||
import {AppState} from "../state/AppState"; |
||||
import {isClientAction, NetworkAction} from "../actions/NetworkAction"; |
||||
import {clientReducer} from "./ClientReducer"; |
||||
import {serverReducer} from "./ServerReducer"; |
||||
|
||||
export function networkReducer(oldState: AppState, action: NetworkAction): AppState { |
||||
if (isClientAction(action)) { |
||||
const newState = clientReducer(oldState.network, action) |
||||
if (newState === oldState.network) |
||||
{ |
||||
return oldState |
||||
} |
||||
return { |
||||
...oldState, |
||||
network: newState |
||||
} |
||||
} else /* if (isServerAction(action)) */ { |
||||
return serverReducer(oldState, action) |
||||
} |
||||
} |
@ -0,0 +1,265 @@ |
||||
import {AppState} from "../state/AppState"; |
||||
import { |
||||
CLIENT_HELLO, |
||||
SendableAction, |
||||
SERVER_FAILED, |
||||
SERVER_GOODBYE, |
||||
SERVER_HELLO, |
||||
SERVER_OK, |
||||
SERVER_REFRESH, |
||||
SERVER_SENT, |
||||
SERVER_SOCKET_STARTUP, |
||||
ServerAction, |
||||
ServerFailedAction, |
||||
ServerGoodbyeAction, |
||||
ServerHelloAction, |
||||
ServerOKAction, |
||||
ServerRefreshAction, |
||||
ServerSentAction, |
||||
ServerSocketStartupAction, |
||||
SocketState, |
||||
SyncableAction, |
||||
} from "../actions/NetworkAction"; |
||||
import {NetworkState, ServerConnectionState} from "../state/NetworkState"; |
||||
import {SyncedState} from "../state/SyncedState"; |
||||
import {applySyncableActions} from "./SyncedStateReducer"; |
||||
import {clientReducer} from "./ClientReducer"; |
||||
|
||||
interface StateRecalculationInputs { |
||||
/** The original server state before the actions changed. The base on which the actions are all applied. */ |
||||
readonly serverState: SyncedState |
||||
/** The permanent actions which will be applied to form the new server state. */ |
||||
readonly permanentActions: readonly SyncableAction[] |
||||
/** |
||||
* Actions which have already been sent to the server and therefore can't be deleted, |
||||
* but have not been approved and can't be applied to the server state. |
||||
* Applied to the local state first. |
||||
*/ |
||||
readonly sentActions: readonly SendableAction[] |
||||
/** |
||||
* Actions which have not yet been sent to the server and therefore can be deleted. |
||||
* Applied to the local state second, and failed or no-effect actions are removed from the list returned. |
||||
*/ |
||||
readonly unsentActions: readonly SendableAction[] |
||||
} |
||||
|
||||
interface StateRecalculationOutputs { |
||||
/** The new server state after applying permanentActions to oldServerState. */ |
||||
readonly newServerState: SyncedState, |
||||
/** The new local state after applying permanentActions, sentActions, and unsentActions (in that order) to oldServerState. */ |
||||
readonly newLocalState: SyncedState, |
||||
/** The unsentActions list from the input, minus any actions which failed or had no effect. */ |
||||
readonly appliedUnsentActions: readonly SendableAction[] |
||||
} |
||||
|
||||
function recalculateStates(input: StateRecalculationInputs): StateRecalculationOutputs { |
||||
const newServerState = applySyncableActions(input.serverState, input.permanentActions).state |
||||
const {state: sentLocalState} = applySyncableActions(newServerState, input.sentActions) |
||||
const {state: newLocalState, appliedActions: appliedUnsentActions} = applySyncableActions(sentLocalState, input.unsentActions) |
||||
return { newServerState, newLocalState, appliedUnsentActions } |
||||
} |
||||
|
||||
function serverHelloReducer(oldState: AppState, action: ServerHelloAction): AppState { |
||||
const { |
||||
newServerState, |
||||
newLocalState, |
||||
appliedUnsentActions, |
||||
} = recalculateStates({ |
||||
serverState: action.state, |
||||
permanentActions: [], |
||||
sentActions: [], |
||||
unsentActions: oldState.network.pendingActions |
||||
}) |
||||
// TODO: The connection state should be AWAITING_HELLO and the special message should be our Hello
|
||||
// TODO: Destroy all pending actions if the server's map has a different GUID from ours.
|
||||
return { |
||||
...oldState, |
||||
localState: newLocalState, |
||||
network: { |
||||
...oldState.network, |
||||
connectionState: ServerConnectionState.CONNECTED, |
||||
specialMessage: null, |
||||
serverState: newServerState, |
||||
pendingActions: appliedUnsentActions, |
||||
reconnectAttempts: 0, |
||||
} |
||||
} |
||||
} |
||||
|
||||
function serverRefreshReducer(oldState: AppState, action: ServerRefreshAction): AppState { |
||||
const { |
||||
newServerState, |
||||
newLocalState, |
||||
appliedUnsentActions, |
||||
} = recalculateStates({ |
||||
serverState: action.state, |
||||
permanentActions: [], |
||||
sentActions: oldState.network.sentActions.map((sent) => sent.action), |
||||
unsentActions: oldState.network.pendingActions |
||||
}) |
||||
// TODO: The connection state should be AWAITING_REFRESH and the special message should be our Refresh
|
||||
// TODO: Destroy all pending actions if the server's map has a different GUID from ours.
|
||||
return { |
||||
...oldState, |
||||
localState: newLocalState, |
||||
network: { |
||||
...oldState.network, |
||||
connectionState: ServerConnectionState.CONNECTED, |
||||
specialMessage: null, |
||||
serverState: newServerState, |
||||
pendingActions: appliedUnsentActions, |
||||
} |
||||
}; |
||||
} |
||||
|
||||
function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeAction): NetworkState { |
||||
// TODO: Sort out the correct state and autoReconnectAt based on the time in the action.
|
||||
return { |
||||
...oldState, |
||||
connectionState: ServerConnectionState.OFFLINE, |
||||
specialMessage: null, |
||||
goodbyeCode: action.code, |
||||
goodbyeReason: action.reason, |
||||
} |
||||
} |
||||
|
||||
function serverOkReducer(oldState: AppState, action: ServerOKAction): AppState { |
||||
if (oldState.network.serverState === null) { |
||||
return oldState |
||||
} |
||||
const okIndexes: {[id: number]: boolean|undefined} = {} |
||||
for (let index = 0; index < action.ids.length; index += 1) { |
||||
okIndexes[action.ids[index]] = true |
||||
} |
||||
const receivedActions = oldState.network.sentActions.filter((sent) => !!okIndexes[sent.id]).map((sent) => sent.action) |
||||
const stillWaitingActions = oldState.network.sentActions.filter((sent) => !okIndexes[sent.id]) |
||||
const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ |
||||
serverState: oldState.network.serverState, |
||||
permanentActions: receivedActions, |
||||
sentActions: stillWaitingActions.map((sent) => sent.action), |
||||
unsentActions: oldState.network.pendingActions, |
||||
}) |
||||
return { |
||||
...oldState, |
||||
localState: newLocalState, |
||||
network: { |
||||
...oldState.network, |
||||
serverState: newServerState, |
||||
sentActions: stillWaitingActions, |
||||
pendingActions: appliedUnsentActions, |
||||
} |
||||
} |
||||
} |
||||
|
||||
function serverFailedReducer(oldState: AppState, action: ServerFailedAction): AppState { |
||||
if (oldState.network.serverState === null) { |
||||
return oldState |
||||
} |
||||
const failedIndexes: {[id: number]: boolean|undefined} = {} |
||||
for (let index = 0; index < action.failures.length; index += 1) { |
||||
failedIndexes[action.failures[index].id] = true |
||||
} |
||||
// TODO: Figure out somewhere to put the failures for logging purposes, so the messages aren't wasted.
|
||||
/* const failedActions = */ |
||||
oldState.network.sentActions.filter((sent) => !!failedIndexes[sent.id]).map((sent) => sent.action) |
||||
const stillWaitingActions = oldState.network.sentActions.filter((sent) => !failedIndexes[sent.id]) |
||||
const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ |
||||
serverState: oldState.network.serverState, |
||||
permanentActions: [], |
||||
sentActions: stillWaitingActions.map((sent) => sent.action), |
||||
unsentActions: oldState.network.pendingActions, |
||||
}) |
||||
return { |
||||
...oldState, |
||||
localState: newLocalState, |
||||
network: { |
||||
...oldState.network, |
||||
serverState: newServerState, |
||||
sentActions: stillWaitingActions, |
||||
pendingActions: appliedUnsentActions, |
||||
} |
||||
} |
||||
} |
||||
|
||||
function serverSocketStartupReducer(oldState: NetworkState, action: ServerSocketStartupAction): NetworkState { |
||||
switch (action.state) { |
||||
case SocketState.OPEN: |
||||
return clientReducer(oldState, { |
||||
type: CLIENT_HELLO, |
||||
version: 1, |
||||
}) |
||||
case SocketState.CONNECTING: |
||||
return { |
||||
...oldState, |
||||
connectionState: ServerConnectionState.CONNECTING, |
||||
serverState: null, |
||||
specialMessage: null, |
||||
goodbyeCode: null, |
||||
goodbyeReason: null, |
||||
autoReconnectAt: null, |
||||
nextID: 0, |
||||
sentActions: [], |
||||
pendingActions: [ |
||||
...oldState.sentActions.map((wrapper) => (wrapper.action)), |
||||
...oldState.pendingActions, |
||||
], |
||||
// Don't clear reconnectAttempts until SERVER_HELLO. Even if we fail _after_ establishing
|
||||
// a connection, we still want to keep using the longer exponential backoff state, or even give up.
|
||||
} |
||||
} |
||||
} |
||||
|
||||
function serverSentReducer(oldState: AppState, action: ServerSentAction): AppState { |
||||
if (oldState.network.serverState === null) { |
||||
return oldState |
||||
} |
||||
const { |
||||
newServerState, |
||||
newLocalState, |
||||
appliedUnsentActions, |
||||
} = recalculateStates({ |
||||
serverState: oldState.network.serverState, |
||||
permanentActions: action.actions, |
||||
sentActions: oldState.network.sentActions.map((sent) => sent.action), |
||||
unsentActions: oldState.network.pendingActions |
||||
}) |
||||
return { |
||||
...oldState, |
||||
localState: newLocalState, |
||||
network: { |
||||
...oldState.network, |
||||
connectionState: ServerConnectionState.CONNECTED, |
||||
serverState: newServerState, |
||||
pendingActions: appliedUnsentActions, |
||||
} |
||||
}; |
||||
} |
||||
|
||||
export function serverReducer(oldState: AppState, action: ServerAction): AppState { |
||||
// TODO: Verify that these messages are only received at the proper times and in the proper states.
|
||||
// e.g., BeginConnecting should only happen when the state is somewhere in the disconnected region.
|
||||
// Goodbye, OK, Failed, and Sent, on the other hand, _can't_ happen then.
|
||||
// Hello and Refresh should only happen when we are explicitly waiting for them.
|
||||
switch (action.type) { |
||||
case SERVER_HELLO: |
||||
return serverHelloReducer(oldState, action); |
||||
case SERVER_REFRESH: |
||||
return serverRefreshReducer(oldState, action); |
||||
case SERVER_GOODBYE: |
||||
return { |
||||
...oldState, |
||||
network: serverGoodbyeReducer(oldState.network, action) |
||||
}; |
||||
case SERVER_OK: |
||||
return serverOkReducer(oldState, action); |
||||
case SERVER_FAILED: |
||||
return serverFailedReducer(oldState, action); |
||||
case SERVER_SOCKET_STARTUP: |
||||
return { |
||||
...oldState, |
||||
network: serverSocketStartupReducer(oldState.network, action) |
||||
} |
||||
case SERVER_SENT: |
||||
return serverSentReducer(oldState, action) |
||||
} |
||||
} |
@ -0,0 +1,50 @@ |
||||
import {SyncedState} from "../state/SyncedState"; |
||||
import {SyncableAction} from "../actions/NetworkAction"; |
||||
import {isMapAction, MapAction} from "../actions/MapAction"; |
||||
import {hexMapReducer} from "./HexMapReducer"; |
||||
import {isUserAction, UserAction} from "../actions/UserAction"; |
||||
import {userReducer} from "./UserReducer"; |
||||
import {exhaustivenessCheck} from "../util/TypeUtils"; |
||||
|
||||
export function syncedStateReducer(state: SyncedState, action: (MapAction|UserAction)): SyncedState { |
||||
if (isMapAction(action)) { |
||||
const newMap = hexMapReducer(state.map, action) |
||||
return newMap === state.map ? state : { |
||||
...state, |
||||
map: newMap |
||||
} |
||||
} else if (isUserAction(action)) { |
||||
const newUser = userReducer(state.user, action) |
||||
return newUser === state.user ? state : { |
||||
...state, |
||||
user: newUser |
||||
} |
||||
} |
||||
exhaustivenessCheck(action) |
||||
} |
||||
|
||||
interface SyncableActionApplicationResults<T> { |
||||
state: SyncedState |
||||
appliedActions: readonly T[] |
||||
} |
||||
/** Applies a sequence of actions, skipping failed actions, and returns an array of the successful ones. */ |
||||
export function applySyncableActions<T extends SyncableAction>(state: SyncedState, actions: readonly T[]): SyncableActionApplicationResults<T> { |
||||
return actions.reduce(({state, appliedActions}: SyncableActionApplicationResults<T>, action) => { |
||||
// Save our breath by reusing the actions array if this is the last action in the list.
|
||||
const resultActions = (appliedActions.length === actions.length - 1) ? actions : [...appliedActions, action] |
||||
try { |
||||
const newState = syncedStateReducer(state, action) |
||||
if (newState === state) { |
||||
// Act as if it wasn't there - it did nothing.
|
||||
return { state, appliedActions } |
||||
} |
||||
return { |
||||
state: newState, |
||||
appliedActions: resultActions |
||||
} |
||||
} catch (e) { |
||||
// Just skip it - continue as if it wasn't there, and don't let the exception escape.
|
||||
return { state, appliedActions } |
||||
} |
||||
}, {state: state, appliedActions: []}) |
||||
} |
@ -0,0 +1,31 @@ |
||||
import {AppState} from "../state/AppState"; |
||||
import {TILE_PAINT, TILE_REMOVE, TileAction} from "../actions/TileAction"; |
||||
import {getCell} from "../state/HexMap"; |
||||
import {CELL_COLOR, CELL_REMOVE} from "../actions/CellAction"; |
||||
import {appStateReducer} from "./AppStateReducer"; |
||||
|
||||
export function tileReducer(oldState: AppState, action: TileAction): AppState { |
||||
if (oldState.localState === null) { |
||||
return oldState |
||||
} |
||||
const {map, user} = oldState.localState |
||||
switch (action.type) { |
||||
case TILE_PAINT: |
||||
if (getCell(map, action.at)?.color === user.activeColor) { |
||||
return oldState |
||||
} |
||||
return appStateReducer(oldState, { |
||||
type: CELL_COLOR, |
||||
at: action.at, |
||||
color: user.activeColor |
||||
}) |
||||
case TILE_REMOVE: |
||||
if (getCell(map, action.at) === null) { |
||||
return oldState |
||||
} |
||||
return appStateReducer(oldState, { |
||||
type: CELL_REMOVE, |
||||
at: action.at |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,15 @@ |
||||
import {USER_ACTIVE_COLOR, UserAction} from "../actions/UserAction"; |
||||
import {UserState} from "../state/UserState"; |
||||
|
||||
export function userReducer(oldState: UserState, action: UserAction): UserState { |
||||
switch (action.type) { |
||||
case USER_ACTIVE_COLOR: |
||||
if (oldState.activeColor === action.color) { |
||||
return oldState |
||||
} |
||||
return { |
||||
...oldState, |
||||
activeColor: action.color |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,8 @@ |
||||
import {NetworkState} from "./NetworkState"; |
||||
import {SyncedState} from "./SyncedState"; |
||||
|
||||
export interface AppState { |
||||
/** The current state of the app with pending actions applied; the local version. */ |
||||
readonly localState: SyncedState | null |
||||
readonly network: NetworkState |
||||
} |
@ -0,0 +1,155 @@ |
||||
/** Cubic (3-dimensional) coordinates for algorithms. */ |
||||
import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap"; |
||||
|
||||
export interface CubicCoordinates { |
||||
/** The cubic x-coordinate. */ |
||||
readonly x: number |
||||
/** The cubic y-coordinate. */ |
||||
readonly y: number |
||||
/** The cubic z-coordinate. */ |
||||
readonly z: number |
||||
} |
||||
|
||||
/** Axial (2-dimensional cube variant) coordinates for display. */ |
||||
export interface AxialCoordinates { |
||||
/** The axial x-coordinate. */ |
||||
readonly q: number |
||||
/** The axial y-coordinate. */ |
||||
readonly r: number |
||||
} |
||||
|
||||
/** Staggered (storage) coordinates for accessing cell storage. */ |
||||
export interface StorageCoordinates { |
||||
/** The index of the line within the map. */ |
||||
readonly line: number |
||||
/** The index of the cell within the line. */ |
||||
readonly cell: number |
||||
} |
||||
|
||||
/** Translates storage coordinates to a key unique among all cells in the map. */ |
||||
export function storageCoordinatesToKey({line, cell}: StorageCoordinates): string { |
||||
return `${line},${cell}` |
||||
} |
||||
|
||||
/** |
||||
* The full set of coordinates on screen for a hexagon. |
||||
* The axis changes depending on the orientation. |
||||
* For POINTY_TOP mode, the flat axis is the X axis, and the pointy axis is the Y axis. |
||||
* For FLAT_TOP mode, the flat axis is the Y axis, and the pointy axis is the X axis. |
||||
* The vertices of a hexagon will always be at these pairs, in this order, with the axes set as above: |
||||
* pointyStart, flatCenter |
||||
* pointyFirstThird, flatStart |
||||
* pointyLastThird, flatStart |
||||
* pointyEnd, flatCenter |
||||
* pointyLastThird, flatEnd |
||||
* pointyFirstThird, flatEnd |
||||
*/ |
||||
export interface RenderCoordinates { |
||||
/** The orientation of the hexagons, for determining which axes to use. */ |
||||
readonly orientation: HexagonOrientation, |
||||
/** The beginning of the pointy axis - where the first pointy vertex is, on the axis that runs through the pointy vertices. */ |
||||
readonly pointyStart: number, |
||||
/** The end of the pointy axis - where the last pointy vertex is, on the axis that runs through the pointy vertices. */ |
||||
readonly pointyEnd: number, |
||||
/** The first "third" (actually a quarter) of the pointy axis - where the first vertex of each flat side is, on the axis that runs through the pointy vertices. */ |
||||
readonly pointyFirstThird: number, |
||||
/** The last "third" (actually a quarter) of the pointy axis - where the last vertex of each flat side is, on the axis that runs through the pointy vertices. */ |
||||
readonly pointyLastThird: number, |
||||
/** The start of the flat axis - where the first flat side is, on the axis that runs through the flat sides. */ |
||||
readonly flatStart: number, |
||||
/** The center of the flat axis - where the two pointy vertices are, on the axis that runs through the flat sides. */ |
||||
readonly flatCenter: number, |
||||
/** The end of the flat axis - where the second flat side is, on the axis that runs through the flat sides. */ |
||||
readonly flatEnd: number, |
||||
} |
||||
|
||||
/** The offsets for rendering a hexagonal map. */ |
||||
export interface RenderOffsets { |
||||
/** The distance from the left the first x coordinate should be. */ |
||||
readonly left: number |
||||
/** The distance from the top the first y coordinate should be. */ |
||||
readonly top: number |
||||
/** How big each hexagon should be. The "radius" from the center to any vertex. */ |
||||
readonly size: number |
||||
/** The way the hex map should be displayed. Usually the same as the origin map. */ |
||||
readonly displayMode: HexMapRepresentation |
||||
} |
||||
|
||||
export interface RenderSize { |
||||
readonly width: number |
||||
readonly height: number |
||||
} |
||||
|
||||
// Heavily based on https://www.redblobgames.com/grids/hexagons/
|
||||
const POINTY_AXIS_FACTOR = 2; |
||||
const FLAT_AXIS_FACTOR = Math.sqrt(3); |
||||
export function storageCoordinatesToRenderCoordinates({line, cell}: StorageCoordinates, renderOffsets: RenderOffsets): RenderCoordinates { |
||||
const {orientation, indentedLines} = renderOffsets.displayMode; |
||||
|
||||
const flatLength = renderOffsets.size * FLAT_AXIS_FACTOR; |
||||
const pointyLength = renderOffsets.size * POINTY_AXIS_FACTOR; |
||||
|
||||
const flatStorageCoordinate = cell; |
||||
const pointyStorageCoordinate = line; |
||||
|
||||
const lineParity = (pointyStorageCoordinate % 2) === 0 ? LineParity.EVEN : LineParity.ODD; |
||||
const isIndented = lineParity === indentedLines; |
||||
|
||||
const flatOffset = orientation === HexagonOrientation.FLAT_TOP ? renderOffsets.top : renderOffsets.left; |
||||
const pointyOffset = orientation === HexagonOrientation.POINTY_TOP ? renderOffsets.top : renderOffsets.left; |
||||
|
||||
const pointyStart = pointyOffset + pointyStorageCoordinate * pointyLength * 3 / 4; |
||||
const pointyFirstThird = pointyStart + pointyLength / 4 |
||||
const pointyLastThird = pointyStart + pointyLength * 3 / 4 |
||||
const pointyEnd = pointyStart + pointyLength; |
||||
|
||||
const flatStart = flatOffset + flatLength * ((isIndented ? 0.5 : 0) + flatStorageCoordinate); |
||||
const flatCenter = flatStart + flatLength / 2; |
||||
const flatEnd = flatStart + flatLength; |
||||
|
||||
return { |
||||
orientation, |
||||
pointyStart, |
||||
pointyFirstThird, |
||||
pointyLastThird, |
||||
pointyEnd, |
||||
flatStart, |
||||
flatCenter, |
||||
flatEnd |
||||
} |
||||
} |
||||
|
||||
export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, bottomMargin = 0}: {offsets: RenderOffsets, lines: number, cells: number, rightMargin?: number, bottomMargin?: number}): RenderSize { |
||||
const { |
||||
top: topMargin, |
||||
left: leftMargin, |
||||
displayMode: { |
||||
orientation, |
||||
indentedLines |
||||
} |
||||
} = offsets; |
||||
const flatMargins = orientation === HexagonOrientation.FLAT_TOP ? topMargin + bottomMargin : leftMargin + rightMargin; |
||||
const pointyMargins = orientation === HexagonOrientation.POINTY_TOP ? topMargin + bottomMargin : leftMargin + rightMargin; |
||||
|
||||
const flatCellLength = offsets.size * FLAT_AXIS_FACTOR; |
||||
const pointyCellLength = offsets.size * POINTY_AXIS_FACTOR; |
||||
|
||||
const hasIndents = lines > 1 || indentedLines === LineParity.EVEN |
||||
|
||||
// Every line will be 3/4 of a cell length apart in the pointy direction;
|
||||
// however, the last line is still one full pointy cell long.
|
||||
const pointyLength = pointyMargins + ((cells - 1) * pointyCellLength * 3 / 4) + pointyCellLength; |
||||
// Every cell will be one full cell length apart in the flat direction;
|
||||
// however, if there are indents, another half cell is needed to accommodate them.
|
||||
const flatLength = flatMargins + lines * flatCellLength + (hasIndents ? flatCellLength / 2 : 0); |
||||
|
||||
return orientation === HexagonOrientation.FLAT_TOP ? { width: pointyLength, height: flatLength } : { width: flatLength, height: pointyLength } |
||||
} |
||||
|
||||
export function renderCoordinatesToPolygonPoints(coords: RenderCoordinates): string { |
||||
if (coords.orientation === HexagonOrientation.FLAT_TOP) { |
||||
return `${coords.pointyStart},${coords.flatCenter} ${coords.pointyFirstThird},${coords.flatStart} ${coords.pointyLastThird},${coords.flatStart} ${coords.pointyEnd},${coords.flatCenter} ${coords.pointyLastThird},${coords.flatEnd} ${coords.pointyFirstThird},${coords.flatEnd}` |
||||
} else /* if (coords.orientation === HexagonOrientation.POINTY_TOP) */ { |
||||
return `${coords.flatCenter},${coords.pointyStart} ${coords.flatStart},${coords.pointyFirstThird} ${coords.flatStart},${coords.pointyLastThird} ${coords.flatCenter},${coords.pointyEnd} ${coords.flatEnd},${coords.pointyLastThird} ${coords.flatEnd},${coords.pointyFirstThird}` |
||||
} |
||||
} |
@ -0,0 +1,106 @@ |
||||
/** Data associated with a single cell of the map. */ |
||||
import {StorageCoordinates} from "./Coordinates"; |
||||
|
||||
export interface HexCell { |
||||
/** A color in #RRGGBBAA format, or null indicating that the cell should simply be hidden altogether. */ |
||||
readonly color: string |
||||
} |
||||
|
||||
export const EMPTY_CELL: HexCell = { |
||||
color: "#FFFFFFFF" |
||||
} |
||||
|
||||
export type HexLine = readonly (HexCell|null)[] |
||||
|
||||
export enum HexagonOrientation { |
||||
/** |
||||
* The pointy side is vertical and the flat side is horizontal. |
||||
* Use the Y axis for pointy coordinates and the X axis for flat coordinates. |
||||
* Lines are rows that go straight across the X axis. |
||||
*/ |
||||
POINTY_TOP = "POINTY_TOP", |
||||
/** |
||||
* The pointy side is horizontal and the flat side is vertical. |
||||
* Use the Y axis for flat coordinates and the X axis for pointy coordinates. |
||||
* Lines are columns that go straight down the Y axis. |
||||
*/ |
||||
FLAT_TOP = "FLAT_TOP", |
||||
} |
||||
|
||||
/** Which lines should be indented - odds or evens. */ |
||||
export enum LineParity { |
||||
/** Odd lines (1, 3, 5, ...) are staggered inward (right or down) by 1/2 cell. */ |
||||
ODD = "ODD", |
||||
/** Even lines (0, 2, 4, ...) are staggered inward (right or down) by 1/2 cell. */ |
||||
EVEN = "EVEN" |
||||
} |
||||
|
||||
/** The type of map this map is. */ |
||||
export interface HexMapRepresentation { |
||||
readonly orientation: HexagonOrientation |
||||
readonly indentedLines: LineParity |
||||
} |
||||
|
||||
/** Data corresponding to an entire hex map. */ |
||||
export interface HexMap { |
||||
/** The way the map is displayed, which also affects how coordinates are calculated. */ |
||||
readonly displayMode: HexMapRepresentation |
||||
/** |
||||
* The number of lines on the map. |
||||
* In ROWS and EVEN_ROWS mode, this is the height of the map. |
||||
* In COLUMNS and EVEN_COLUMNS mode, this is the width of the map. |
||||
*/ |
||||
readonly lines: number |
||||
/** |
||||
* The number of cells per line. |
||||
* In ROWS and EVEN_ROWS mode, this is the width of the map. |
||||
* In COLUMNS and EVEN_COLUMNS mode, this is the height of the map. |
||||
*/ |
||||
readonly cells_per_line: number |
||||
/** |
||||
* The list of tile lines. There are always exactly {lines} lines. |
||||
* Lines have a constant length; there are always exactly {cells_per_line} cells in a line. |
||||
* In COLUMNS and EVEN_COLUMNS mode, lines represent columns. |
||||
* In ROWS and EVEN_ROWS mode, lines represent rows. |
||||
*/ |
||||
readonly lineCells: readonly HexLine[] |
||||
/** |
||||
* A unique identifier for this map. Lets the client know when it is connecting to a different map with the same |
||||
* name as this one, and its old map has been destroyed. |
||||
*/ |
||||
readonly guid: string |
||||
} |
||||
|
||||
export function initializeMap({lines, cells_per_line, displayMode, guid}: {lines: number, cells_per_line: number, displayMode: HexMapRepresentation, guid: string}): HexMap { |
||||
const lineCells: HexLine[] = []; |
||||
const emptyLine: HexCell[] = []; |
||||
for (let cell = 0; cell < cells_per_line; cell += 1) { |
||||
emptyLine.push(EMPTY_CELL) |
||||
} |
||||
for (let line = 0; line < lines; line += 1) { |
||||
lineCells.push(emptyLine) |
||||
} |
||||
return { |
||||
lines, |
||||
cells_per_line, |
||||
displayMode, |
||||
lineCells, |
||||
guid |
||||
} |
||||
} |
||||
|
||||
export function isValidCoordinate(map: HexMap, {line, cell}: StorageCoordinates) { |
||||
return line >= 0 && line < map.lines && Number.isInteger(line) |
||||
&& cell >= 0 && cell < map.cells_per_line && Number.isInteger(cell); |
||||
} |
||||
|
||||
export function areCellsEquivalent(left: HexCell|null, right: HexCell|null): boolean { |
||||
return left === right || (left?.color === right?.color) |
||||
} |
||||
|
||||
export function getCell(map: HexMap, {line, cell}: StorageCoordinates): HexCell|null { |
||||
if (!isValidCoordinate(map, {line, cell})) { |
||||
return null |
||||
} |
||||
return map.lineCells[line][cell] |
||||
} |
@ -0,0 +1,65 @@ |
||||
import {ClientHelloAction, ClientRefreshAction, SendableAction, SentAction} from "../actions/NetworkAction"; |
||||
import {SyncedState} from "./SyncedState"; |
||||
|
||||
export enum ServerConnectionState { |
||||
/** Used when the client is going through the WebSockets connection process and sending a Hello. */ |
||||
CONNECTING = "CONNECTING", |
||||
/** Used when the client has sent a hello, and is waiting for the server to respond. */ |
||||
AWAITING_HELLO = "AWAITING_HELLO", |
||||
/** Used when the client has sent a refresh request, and is waiting for the server to respond. */ |
||||
AWAITING_REFRESH = "AWAITING_REFRESH", |
||||
/** Used when the client is connected and everything is normal. */ |
||||
CONNECTED = "CONNECTED", |
||||
/** |
||||
* Used when the client is disconnected and not currently connecting, |
||||
* such as when waiting for the automatic reconnect, or if the browser is currently in offline mode, |
||||
* or if the client was disconnected due to a protocol error. |
||||
*/ |
||||
OFFLINE = "OFFLINE", |
||||
} |
||||
|
||||
export interface NetworkState { |
||||
/** |
||||
* The current state of the server, as this client knows it. |
||||
* Used to keep a clean state for applying pending actions to if, for any reason, the current state needs to be |
||||
* recalculated from the server and pending states (e.g., if a server action comes in, or a pending action |
||||
* is applied). |
||||
* |
||||
* Null iff the connection has never been established. |
||||
*/ |
||||
readonly serverState: SyncedState|null |
||||
/** |
||||
* The current state of the connection. |
||||
*/ |
||||
readonly connectionState: ServerConnectionState |
||||
/** |
||||
* A special action that should take precedence over sending more actions. |
||||
*/ |
||||
readonly specialMessage: ClientHelloAction|ClientRefreshAction|null |
||||
/** |
||||
* The ID of the next ClientSentAction to be created. |
||||
*/ |
||||
readonly nextID: number |
||||
/** |
||||
* Messages that were sent to the server but have not yet received a response. |
||||
* These come before pendingActions. |
||||
*/ |
||||
readonly sentActions: readonly SentAction[] |
||||
/** Yet-unsent actions that originated here. These come after sentActions. */ |
||||
readonly pendingActions: readonly SendableAction[] |
||||
/** |
||||
* The error code of the close message. |
||||
* Non-null if and only if the current state is OFFLINE or REJECTED. |
||||
* -1 means no onClose was called, and the reason is a stringized error from onError instead. |
||||
*/ |
||||
readonly goodbyeCode: number | null |
||||
/** |
||||
* The error reason of the close message. |
||||
* Non-null if and only if the current state is OFFLINE or REJECTED. |
||||
*/ |
||||
readonly goodbyeReason: string | null |
||||
/** The time the client will attempt to reconnect, if at all. */ |
||||
readonly autoReconnectAt: Date | null |
||||
/** The number of attempts at reconnecting. */ |
||||
readonly reconnectAttempts: number | null |
||||
} |
@ -0,0 +1,7 @@ |
||||
import {HexMap} from "./HexMap"; |
||||
import {UserState} from "./UserState"; |
||||
|
||||
export interface SyncedState { |
||||
readonly map: HexMap |
||||
readonly user: UserState |
||||
} |
@ -0,0 +1,3 @@ |
||||
export interface UserState { |
||||
readonly activeColor: string |
||||
} |
@ -0,0 +1,79 @@ |
||||
import {CustomPicker, InjectedColorProps} from "react-color"; |
||||
import {ReactElement, useMemo} from "react"; |
||||
import { |
||||
renderCoordinatesToPolygonPoints, |
||||
RenderOffsets, |
||||
storageCoordinatesToRenderCoordinates |
||||
} from "../state/Coordinates"; |
||||
import {HexagonOrientation, LineParity} from "../state/HexMap"; |
||||
|
||||
function HexSwatch({color, index, offsets, classNames, onClick}: {color: string, index: number, offsets: RenderOffsets, classNames?: readonly string[], onClick: () => void}): ReactElement { |
||||
const renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates({line: index, cell: 0}, offsets), [index, offsets]); |
||||
const points = useMemo(() => renderCoordinatesToPolygonPoints(renderCoordinates), [renderCoordinates]); |
||||
return <polygon |
||||
className={classNames !== undefined ? `hexagon swatch ${classNames.join(" ")}` : "hexagon swatch"} |
||||
fill={color} |
||||
points={points} |
||||
onClick={onClick} |
||||
onContextMenu={(e) => e.preventDefault()}/> |
||||
} |
||||
|
||||
const ACTIVE_OFFSETS: RenderOffsets = { |
||||
displayMode: { |
||||
orientation: HexagonOrientation.POINTY_TOP, |
||||
indentedLines: LineParity.ODD |
||||
}, |
||||
top: 13, |
||||
left: 13, |
||||
size: 50, |
||||
} |
||||
|
||||
const SWATCH_OFFSETS: RenderOffsets = { |
||||
displayMode: { |
||||
orientation: HexagonOrientation.FLAT_TOP, |
||||
indentedLines: LineParity.EVEN |
||||
}, |
||||
top: 34, |
||||
left: 110, |
||||
size: 30, |
||||
} |
||||
|
||||
const COLORS: readonly string[] = [ |
||||
"#000000FF", "#555555FF", |
||||
"#800000FF", "#FF0000FF", |
||||
"#008000FF", "#00FF00FF", |
||||
"#808000FF", "#FFFF00FF", |
||||
"#000080FF", "#0000FFFF", |
||||
"#800080FF", "#FF00FFFF", |
||||
"#008080FF", "#00FFFFFF", |
||||
"#AAAAAAFF", "#FFFFFFFF", |
||||
] |
||||
|
||||
function normalizeColor(hex: string): string { |
||||
hex = hex.toUpperCase() |
||||
if (hex.length === 4) { |
||||
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}FF` |
||||
} |
||||
if (hex.length === 5) { |
||||
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}` |
||||
} |
||||
if (hex.length === 7) { |
||||
return `${hex}FF` |
||||
} |
||||
return hex |
||||
} |
||||
|
||||
function HexColorPicker({ hex, onChange }: InjectedColorProps): ReactElement { |
||||
const selected = COLORS.indexOf(normalizeColor(hex || "#INVALID")) |
||||
const swatches = COLORS.map((color, index) => |
||||
index === selected ? null : <HexSwatch key={index} classNames={["preset"]} color={color} index={index} offsets={SWATCH_OFFSETS} onClick={() => onChange !== undefined ? onChange(color) : null} /> |
||||
) |
||||
return <svg viewBox={"0 0 855 126"} width={"855"} height={"126"} className={"hexColorPicker"}> |
||||
<rect className={"colorPickerBackground"} x={0} y={0} width={855} height={126} rx={15} /> |
||||
<HexSwatch classNames={["active"]} color={hex || "#000000FF"} index={0} offsets={ACTIVE_OFFSETS} onClick={() => null} /> |
||||
{swatches} |
||||
{selected !== -1 ? <HexSwatch key={selected} classNames={["preset", "selected"]} color={COLORS[selected]} index={selected} offsets={SWATCH_OFFSETS} onClick={() => onChange !== undefined ? onChange(COLORS[selected]) : null} /> : null} |
||||
</svg> |
||||
} |
||||
|
||||
export default CustomPicker(HexColorPicker); |
@ -0,0 +1,22 @@ |
||||
import React, {ReactElement} from "react"; |
||||
import {getCell, HexMap} from "../state/HexMap"; |
||||
import {HexTileRenderer} from "./HexTileRenderer"; |
||||
import {RenderOffsets, storageCoordinatesToKey} from "../state/Coordinates"; |
||||
|
||||
function HexMapRenderer({map, offsets}: {map: HexMap, offsets: RenderOffsets}) { |
||||
const tiles: ReactElement[] = []; |
||||
for (let line = 0; line < map.lines; line += 1) { |
||||
for (let cell = 0; cell < map.cells_per_line; cell += 1) { |
||||
const coords = {line, cell}; |
||||
const cellData = getCell(map, coords); |
||||
if (cellData !== null) { |
||||
tiles.push( |
||||
<HexTileRenderer key={storageCoordinatesToKey(coords)} coords={coords} cell={cellData} offsets={offsets} /> |
||||
); |
||||
} |
||||
} |
||||
} |
||||
return <g>{tiles}</g> |
||||
} |
||||
|
||||
export default HexMapRenderer |
@ -0,0 +1,45 @@ |
||||
import {MouseEvent as ReactMouseEvent, ReactElement, useContext, useMemo} from "react"; |
||||
import {HexCell} from "../state/HexMap"; |
||||
import { |
||||
renderCoordinatesToPolygonPoints, RenderOffsets, |
||||
StorageCoordinates, |
||||
storageCoordinatesToRenderCoordinates |
||||
} from "../state/Coordinates"; |
||||
import {DispatchContext} from "./context/DispatchContext"; |
||||
import {TILE_PAINT, TILE_REMOVE} from "../actions/TileAction"; |
||||
|
||||
export function HexTileRenderer({classNames, coords, cell, offsets}: {classNames?: readonly string[], coords: StorageCoordinates, cell: HexCell, offsets: RenderOffsets}): ReactElement { |
||||
const dispatch = useContext(DispatchContext); |
||||
|
||||
const LMB = 1; |
||||
const RMB = 2; |
||||
|
||||
function onMouse(e: ReactMouseEvent<SVGPolygonElement, MouseEvent>) { |
||||
if (dispatch === null) { |
||||
return; |
||||
} |
||||
if (e.buttons === LMB) { |
||||
dispatch({ |
||||
type: TILE_PAINT, |
||||
at: coords, |
||||
}); |
||||
} |
||||
if (e.buttons === RMB) { |
||||
dispatch({ |
||||
type: TILE_REMOVE, |
||||
at: coords, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
const renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates(coords, offsets), [coords, offsets]); |
||||
const points = useMemo(() => renderCoordinatesToPolygonPoints(renderCoordinates), [renderCoordinates]) |
||||
|
||||
return <polygon |
||||
className={classNames !== undefined ? `hexagon mapTile ${classNames.join(" ")}` : "hexagon mapTile"} |
||||
fill={cell.color} |
||||
points={points} |
||||
onMouseDown={onMouse} |
||||
onMouseMove={onMouse} |
||||
onContextMenu={(e) => e.preventDefault()}/> |
||||
} |
@ -0,0 +1,4 @@ |
||||
import { createContext } from "react"; |
||||
import {AppAction} from "../../actions/AppAction"; |
||||
|
||||
export const DispatchContext = createContext<null|((action: AppAction) => void)>(null); |
@ -0,0 +1,176 @@ |
||||
import { |
||||
ActionFailure, |
||||
CLIENT_SENT, |
||||
ClientAction, |
||||
ClientSentAction, |
||||
isClientSentAction, |
||||
SendableAction, |
||||
SentAction, |
||||
SERVER_FAILED, |
||||
SERVER_GOODBYE, |
||||
SERVER_HELLO, |
||||
SERVER_OK, |
||||
SERVER_SENT, |
||||
SERVER_SOCKET_STARTUP, |
||||
ServerAction, |
||||
SocketState |
||||
} from "../../actions/NetworkAction"; |
||||
import {HexagonOrientation, HexMapRepresentation, initializeMap, LineParity} from "../../state/HexMap"; |
||||
import {ReactElement, useContext, useEffect, useRef, useState} from "react"; |
||||
import {DispatchContext} from "../context/DispatchContext"; |
||||
import {USER_ACTIVE_COLOR} from "../../actions/UserAction"; |
||||
import {CELL_COLOR} from "../../actions/CellAction"; |
||||
|
||||
export enum OrientationConstants { |
||||
ROWS = "ROWS", |
||||
COLUMNS = "COLUMNS", |
||||
EVEN_ROWS = "EVEN_ROWS", |
||||
EVEN_COLUMNS = "EVEN_COLUMNS" |
||||
} |
||||
export function orientationFromString(string: string): HexMapRepresentation { |
||||
const normalized = string.toUpperCase().trim() |
||||
switch (normalized) { |
||||
case OrientationConstants.ROWS: |
||||
return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } |
||||
case OrientationConstants.COLUMNS: |
||||
return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.ODD } |
||||
case OrientationConstants.EVEN_ROWS: |
||||
return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.EVEN } |
||||
case OrientationConstants.EVEN_COLUMNS: |
||||
return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.EVEN } |
||||
default: |
||||
return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } |
||||
} |
||||
} |
||||
|
||||
/** Fake "connection" to a "server" that actually just goes back and forth with the console. */ |
||||
export class ConsoleConnection { |
||||
public receivedMessages: ClientAction[] = [] |
||||
private dispatch: (action: ServerAction) => void |
||||
|
||||
constructor(dispatch: (action: ServerAction) => void) { |
||||
this.dispatch = dispatch |
||||
} |
||||
|
||||
receive(action: ClientAction): void { |
||||
this.receivedMessages.push(action) |
||||
if (isClientSentAction(action)) { |
||||
console.log(`Received Sent action containing: ${action.nested.map((value) => `${value.id}/${value.action.type}`).join(", ")}`) |
||||
} else { |
||||
console.log(`Received: ${action.type}`) |
||||
} |
||||
} |
||||
|
||||
public sendSocketConnecting(): void { |
||||
this.dispatch({ |
||||
type: SERVER_SOCKET_STARTUP, |
||||
state: SocketState.CONNECTING |
||||
}) |
||||
} |
||||
|
||||
public sendSocketConnected(): void { |
||||
this.dispatch({ |
||||
type: SERVER_SOCKET_STARTUP, |
||||
state: SocketState.OPEN |
||||
}) |
||||
} |
||||
|
||||
public sendHello({color = "#0000FF", displayMode = "ROWS", guid = "TotallyCoolGUID", lines = 10, cells = 10}: { |
||||
color?: string, |
||||
displayMode?: string, |
||||
guid?: string, |
||||
lines?: number, |
||||
cells?: number |
||||
} = {}): void { |
||||
this.dispatch({ |
||||
type: SERVER_HELLO, |
||||
version: 1, |
||||
state: { |
||||
map: initializeMap({ |
||||
lines, |
||||
cells_per_line: cells, |
||||
displayMode: orientationFromString(displayMode), |
||||
guid |
||||
}), |
||||
user: {activeColor: color} |
||||
} |
||||
}) |
||||
} |
||||
|
||||
public sendOK(ids: readonly number[]): void { |
||||
this.dispatch({ |
||||
type: SERVER_OK, |
||||
ids |
||||
}) |
||||
} |
||||
|
||||
public sendFailed(failures: readonly ActionFailure[]): void { |
||||
this.dispatch({ |
||||
type: SERVER_FAILED, |
||||
failures |
||||
}) |
||||
} |
||||
|
||||
public sendGoodbye({code = 1000, reason = "Okay, bye then!"}: { code?: number, reason?: string } = {}): void { |
||||
this.dispatch({ |
||||
type: SERVER_GOODBYE, |
||||
code, |
||||
reason, |
||||
currentTime: new Date() |
||||
}) |
||||
} |
||||
|
||||
public sendColorChange(color: string = "#FF0000FF"): void { |
||||
this.dispatch({ |
||||
type: SERVER_SENT, |
||||
actions: [{ |
||||
type: USER_ACTIVE_COLOR, |
||||
color |
||||
}] |
||||
}) |
||||
} |
||||
|
||||
public sendColorAtTile(color: string = "#FFFF00FF", line: number = 0, cell: number = 0): void { |
||||
this.dispatch({ |
||||
type: SERVER_SENT, |
||||
actions: [{ |
||||
type: CELL_COLOR, |
||||
at: { line, cell }, |
||||
color |
||||
}] |
||||
}) |
||||
} |
||||
} |
||||
|
||||
|
||||
export function ConsoleConnector({specialMessage, pendingMessages, nextID}: {specialMessage: ClientAction|null, pendingMessages: readonly SendableAction[], nextID: number}): ReactElement { |
||||
const dispatch = useContext(DispatchContext) |
||||
const connector = useRef(new ConsoleConnection(dispatch || (() => null))) |
||||
const [lastSpecialMessage, setLastSpecialMessage] = useState<ClientAction|null>(null) |
||||
useEffect(() => { |
||||
// @ts-ignore
|
||||
window.fakedServerConnection = connector.current |
||||
}, []); |
||||
useEffect(() => { |
||||
if (dispatch !== null) { |
||||
if (pendingMessages.length > 0) { |
||||
const sentMessages: SentAction[] = pendingMessages.map((action, index) => { |
||||
return { id: index + nextID, action } |
||||
}); |
||||
const sentMessage: ClientSentAction = { |
||||
type: CLIENT_SENT, |
||||
nested: sentMessages |
||||
}; |
||||
connector.current.receive(sentMessage) |
||||
dispatch(sentMessage) |
||||
} |
||||
} |
||||
}, [nextID, dispatch, pendingMessages]) |
||||
useEffect(() => { |
||||
if (specialMessage !== null && specialMessage !== lastSpecialMessage) { |
||||
connector.current.receive(specialMessage); |
||||
setLastSpecialMessage(specialMessage); |
||||
} |
||||
}, [specialMessage, lastSpecialMessage, setLastSpecialMessage]) |
||||
return <div className="consoleConnector">Console connection active</div> |
||||
} |
@ -0,0 +1,7 @@ |
||||
export function arrayShallowEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
||||
return left.length === right.length && arrayShallowStartsWith(left, right) |
||||
} |
||||
|
||||
export function arrayShallowStartsWith<T>(target: readonly T[], prefix: readonly T[]): boolean { |
||||
return target.length >= prefix.length && prefix.every((value, index) => target[index] === value) |
||||
} |
@ -0,0 +1,4 @@ |
||||
/** Used when Typescript hasn't figured out that we can never reach a particular branch. */ |
||||
export function exhaustivenessCheck(_param: never): never { |
||||
throw Error("This cannot be...") |
||||
} |
Loading…
Reference in new issue