Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ps/games/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ export class BaseGame<State extends BaseState> {
user.pageHTML(html, { name: this.id, room: this.room });
}

update(user?: string) {
update(user?: string): void {
if (!this.started) return;
if (user) {
const asPlayer = Object.values(this.players).find(player => player.id === user);
Expand Down
5 changes: 5 additions & 0 deletions src/ps/games/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LightsOut, meta as LightsOutMeta } from '@/ps/games/lightsout';
import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind';
import { Othello, meta as OthelloMeta } from '@/ps/games/othello';
import { Scrabble, meta as ScrabbleMeta } from '@/ps/games/scrabble';
import { SnakesLadders, meta as SnakesLaddersMeta } from '@/ps/games/snakesladders';
import { GamesList, type Meta } from '@/ps/games/types';

export const Games = {
Expand Down Expand Up @@ -31,5 +32,9 @@ export const Games = {
meta: ScrabbleMeta,
instance: Scrabble,
},
[GamesList.SnakesLadders]: {
meta: SnakesLaddersMeta,
instance: SnakesLadders,
},
} satisfies Readonly<Record<GamesList, Readonly<{ meta: Meta; instance: unknown }>>>;
export type Games = typeof Games;
53 changes: 53 additions & 0 deletions src/ps/games/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,56 @@ export function Table<T>({
</table>
);
}

function Pip({ style }: { style?: CSSProperties }): ReactElement {
return <div style={{ background: 'black', borderRadius: 99, width: 10, height: 10, position: 'absolute', ...style }} />;
}

export function Dice({ value, style }: { value: number; style?: CSSProperties }): ReactElement | null {
if (!value || value > 6) return null;
return (
<center style={{ position: 'relative', background: 'white', height: 42, width: 42, borderRadius: 4, ...style }}>
{value === 1 ? <Pip style={{ top: 16, left: 16 }} /> : null}
{value === 2 ? (
<>
<Pip style={{ top: 16, left: 7 }} />
<Pip style={{ top: 16, right: 7 }} />
</>
) : null}
{value === 3 ? (
<>
<Pip style={{ top: 5, right: 5 }} />
<Pip style={{ top: 16, left: 16 }} />
<Pip style={{ bottom: 5, left: 5 }} />
</>
) : null}
{value === 4 ? (
<>
<Pip style={{ top: 7, left: 7 }} />
<Pip style={{ top: 7, right: 7 }} />
<Pip style={{ bottom: 7, left: 7 }} />
<Pip style={{ bottom: 7, right: 7 }} />
</>
) : null}
{value === 5 ? (
<>
<Pip style={{ top: 5, left: 5 }} />
<Pip style={{ top: 5, right: 5 }} />
<Pip style={{ top: 16, left: 16 }} />
<Pip style={{ bottom: 5, left: 5 }} />
<Pip style={{ bottom: 5, right: 5 }} />
</>
) : null}
{value === 6 ? (
<>
<Pip style={{ top: 4, left: 8 }} />
<Pip style={{ top: 4, right: 8 }} />
<Pip style={{ top: 16, left: 8 }} />
<Pip style={{ top: 16, right: 8 }} />
<Pip style={{ bottom: 4, left: 8 }} />
<Pip style={{ bottom: 4, right: 8 }} />
</>
) : null}
</center>
);
}
2 changes: 1 addition & 1 deletion src/ps/games/scrabble/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export const meta: Meta = {
name: 'Scrabble',
id: GamesList.Scrabble,
aliases: ['scrab'],
players: 'many',

players: 'many',
minSize: 2,
maxSize: 4,

Expand Down
158 changes: 158 additions & 0 deletions src/ps/games/snakesladders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { BaseGame } from '@/ps/games/game';
import { render } from '@/ps/games/snakesladders/render';
import { sample } from '@/utils/random';
import { range } from '@/utils/range';

import type { ToTranslate, TranslatedText } from '@/i18n/types';
import type { BaseContext } from '@/ps/games/game';
import type { Log } from '@/ps/games/snakesladders/logs';
import type { RenderCtx, State, WinCtx } from '@/ps/games/snakesladders/types';
import type { ActionResponse, EndType } from '@/ps/games/types';
import type { User } from 'ps-client';
import type { ReactNode } from 'react';

export { meta } from '@/ps/games/snakesladders/meta';

export class SnakesLadders extends BaseGame<State> {
log: Log[] = [];
winCtx?: WinCtx | { type: EndType };
frames: ReactNode[] = [];

ladders: [number, number][] = [
[1, 38],
[4, 14],
[8, 30],
[21, 42],
[28, 76],
[50, 67],
[71, 92],
[80, 99],
];
snakes: [number, number][] = [
[32, 10],
[36, 6],
[48, 26],
[62, 18],
[88, 24],
[95, 56],
[97, 78],
];

constructor(ctx: BaseContext) {
super(ctx);
super.persist(ctx);

if (ctx.backup) return;
this.state.board = {};
this.state.lastRoll = 0;
}

onStart(): ActionResponse {
const colors = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#9e00ff', '#ff00ff'].shuffle();
Object.values(this.players).forEach(
player => (this.state.board[player.id] = { pos: 0, name: player.name, color: colors.shift()! })
);
return { success: true, data: null };
}

action(user: User): void {
if (!this.started) this.throw('GAME.NOT_STARTED');
if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT');
this.roll();
}

roll(): void {
const player = this.turn!;
const current = this.state.board[player].pos;
const dice = 1 + sample(6, this.prng);
this.state.lastRoll = dice;
if (current + dice > 100) {
this.room.privateSend(
player,
`You rolled a ${dice}, but needed a ${100 - current}${100 - current === 1 ? '' : ' or lower'}...` as ToTranslate
);
this.nextPlayer();
return;
}

let final = current + dice;
const frameNums = range(current, final, dice + 1);
const onSnekHead = this.snakes.find(snek => snek[0] === final);
if (onSnekHead) {
final = onSnekHead[1];
frameNums.push(final);
}
const onLadderFoot = this.ladders.find(ladder => ladder[0] === final);
if (onLadderFoot) {
final = onLadderFoot[1];
frameNums.push(final);
}
this.state.board[player].pos = final;

this.log.push({ turn: player, time: new Date(), action: 'roll', ctx: dice });

if (final === 100) {
this.winCtx = { type: 'win', winner: { ...this.players[current], board: this.state.board } };
this.end();
return;
}

this.frames = frameNums.map(pos => this.render(null, pos));

this.nextPlayer();
}

update(user?: string): void {
if (this.frames.length > 0) {
if (user) return; // Don't send the page if animating
this.room.pageHTML(
[
...Object.values(this.players)
.filter(player => !player.out)
.map(player => player.id),
...this.spectators,
],
this.frames.shift(),
{ name: this.id }
);
if (this.frames.length > 0) setTimeout(() => this.update(), 500);
else setTimeout(() => super.update(), 500);
return;
} else super.update(user);
}

onEnd(type?: EndType): TranslatedText {
if (type) {
this.winCtx = { type };
if (type === 'dq') return this.$T('GAME.ENDED_AUTOMATICALLY', { game: this.meta.name, id: this.id });
return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id });
}
return this.$T('GAME.WON', { winner: this.turn! });
}

render(side: string | null, override?: number) {
const ctx: RenderCtx = {
board:
override && this.turn
? { ...this.state.board, [this.turn]: { ...this.state.board[this.turn], pos: override } }
: this.state.board,
lastRoll: this.state.lastRoll,
id: this.id,
active: side === this.turn && !!side,
};
if (this.winCtx) {
ctx.header = this.$T('GAME.GAME_ENDED');
} else if (typeof override === 'number') {
ctx.header = `${this.turn} rolled a ${this.state.lastRoll}...`;
} else if (side === this.turn) {
ctx.header = this.$T('GAME.YOUR_TURN');
} else if (side) {
ctx.header = this.$T('GAME.WAITING_FOR_OPPONENT');
ctx.dimHeader = true;
} else if (this.turn) {
const current = this.players[this.turn];
ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: `${current.name}${this.sides ? ` (${this.turn})` : ''}` });
}
return render.bind(this.renderCtx)(ctx);
}
}
18 changes: 18 additions & 0 deletions src/ps/games/snakesladders/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { BaseLog } from '@/ps/games/types';
import type { Satisfies, SerializedInstance } from '@/types/common';

export type Log = Satisfies<
BaseLog,
{
time: Date;
turn: string;
} & (
| {
action: 'roll';
ctx: number;
}
| { action: 'skip'; ctx: null }
)
>;

export type APILog = SerializedInstance<Log>;
18 changes: 18 additions & 0 deletions src/ps/games/snakesladders/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { GamesList } from '@/ps/games/types';
import { fromHumanTime } from '@/tools';

import type { Meta } from '@/ps/games/types';

export const meta: Meta = {
name: 'Snakes & Ladders',
id: GamesList.SnakesLadders,
aliases: ['sl', 'snakesandladders', 'snakes'],

players: 'many',
minSize: 2,
maxSize: 4,

autostart: false,
pokeTimer: fromHumanTime('30 sec'),
timer: fromHumanTime('45 sec'),
};
102 changes: 102 additions & 0 deletions src/ps/games/snakesladders/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Dice, Table } from '@/ps/games/render';
import { createGrid } from '@/ps/games/utils';
import { Username } from '@/utils/components';
import { Button } from '@/utils/components/ps';

import type { CellRenderer } from '@/ps/games/render';
import type { RenderCtx } from '@/ps/games/snakesladders/types';
import type { ReactElement } from 'react';

type This = { msg: string };

function Player({ color, as: As = 'td' }: { color: string; as?: 'td' | 'div' }): ReactElement {
return (
<As
style={{
borderRadius: 100,
backgroundColor: color,
height: 15,
width: 15,
opacity: 0.8,
border: '1px solid #222',
display: As === 'div' ? 'inline-block' : undefined,
}}
/>
);
}

function Players({ players }: { players: { pos: number; color: string }[] }): ReactElement {
return (
<table>
<tbody>
<tr>
{players.slice(0, 2).map(player => (
<Player color={player.color} />
))}
</tr>
{players.length > 2 ? (
<tr>
{players.slice(2).map(player => (
<Player color={player.color} />
))}
</tr>
) : null}
</tbody>
</table>
);
}

const TEN_BY_TEN = createGrid<null>(10, 10, () => null);

export function renderBoard(this: This, ctx: RenderCtx) {
const Cell: CellRenderer<null> = ({ i, j }) => {
const displayNum = (10 - i - 1) * 10 + (i % 2 ? j + 1 : 10 - j);
const players = Object.values(ctx.board).filter(player => player.pos === displayNum);

return <td style={{ height: 45, width: 45 }}>{players.length ? <Players players={players} /> : null}</td>;
};

return (
<Table<null>
board={TEN_BY_TEN}
labels={null}
Cell={Cell}
style={{
backgroundImage: `url('${process.env.WEB_URL}/static/snakesladders/main.png')`,
backgroundSize: 'contain',
}}
/>
);
}

export function render(this: This, ctx: RenderCtx): ReactElement {
return (
<center>
<h1 style={ctx.dimHeader ? { color: 'gray' } : {}}>{ctx.header}</h1>
{renderBoard.bind(this)(ctx)}
<b style={{ margin: 10 }}>
{ctx.lastRoll ? (
<>
Last Roll: <Dice value={ctx.lastRoll} style={{ display: 'inline-block', zoom: '60%' }} />
<br />
</>
) : null}
</b>
{ctx.active ? (
<>
<br />
<Button value={`${this.msg} !`}>Roll!</Button>
</>
) : null}
<br />
<br />
{Object.values(ctx.board)
.map(player => (
<>
<Player color={player.color} as="div" /> <Username name={player.name} clickable />
</>
))
.space(<br />)}
</center>
);
}
Loading
Loading