parent
f5a9f3fde6
commit
bd714aa1e8
@ -1,38 +1,94 @@ |
|||||||
.App { |
body { |
||||||
text-align: center; |
text-align: center; |
||||||
|
background-color: #282c34; |
||||||
} |
} |
||||||
|
|
||||||
.App-logo { |
.App, .scrollbox { |
||||||
height: 40vmin; |
position: absolute; |
||||||
pointer-events: none; |
top: 0; |
||||||
|
bottom: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
} |
} |
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) { |
.scrollbox { |
||||||
.App-logo { |
overflow-x: scroll; |
||||||
animation: App-logo-spin infinite 20s linear; |
overflow-y: scroll; |
||||||
} |
|
||||||
} |
} |
||||||
|
.centerbox { |
||||||
.App-header { |
|
||||||
background-color: #282c34; |
|
||||||
min-height: 100vh; |
|
||||||
display: flex; |
display: flex; |
||||||
flex-direction: column; |
|
||||||
align-items: center; |
align-items: center; |
||||||
justify-content: center; |
justify-content: center; |
||||||
font-size: calc(10px + 2vmin); |
align-content: center; |
||||||
color: white; |
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 { |
.mapTile { |
||||||
color: #61dafb; |
stroke: black; |
||||||
|
stroke-width: 2; |
||||||
|
stroke-linejoin: round; |
||||||
} |
} |
||||||
|
|
||||||
@keyframes App-logo-spin { |
.swatch { |
||||||
from { |
stroke: black; |
||||||
transform: rotate(0deg); |
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; |
||||||
|
} |
||||||
|
.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; |
||||||
} |
} |
||||||
to { |
.centerbox { |
||||||
transform: rotate(360deg); |
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