Skip to content
This repository has been archived by the owner on Dec 12, 2024. It is now read-only.

Commit

Permalink
feat: generate solvable game; add undo button; remove <K> type
Browse files Browse the repository at this point in the history
  • Loading branch information
dantetemplar committed Nov 29, 2024
1 parent f071375 commit b0f7091
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 68 deletions.
68 changes: 29 additions & 39 deletions frontend/src/components/mahjong/Mahjong.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,50 @@
import type { Tile as TileT } from './game'
import { useState } from 'react'
import { FieldTemplate, TEMPLATE_1 } from './field-template'
import { FieldTemplate, TEMPLATE_2 } from './field-template'
import { Game } from './game'
import { Tile } from './Tile'

export function Mahjong() {
const [tiles, setTiles] = useState<TileT<string>[]>([])
const [selected, setSelected] = useState<TileT<string> | null>(null)
const [tiles, setTiles] = useState<TileT[]>([])
const [selected, setSelected] = useState<TileT | null>(null)

const [game, _] = useState(() => {
const g = Game.random<string>(
FieldTemplate.decode(TEMPLATE_1),
const g = Game.random(
FieldTemplate.decode(TEMPLATE_2),
(a, b) => a === b,
['atom', 'bentley', 'tesla'],
['atom', 'bentley', 'tesla', 'atom', 'bentley'],
)
g.onTilesChange = setTiles
g.onSelectedTileChange = setSelected
setTiles(g.tiles())
return g
})

const handleUndo = () => {
game.undoLastMove()
}

// sort tiles by z, then by x, then by y
tiles.sort((a, b) => {
if (a.coord.z !== b.coord.z) {
return a.coord.z - b.coord.z
}
if (a.coord.x !== b.coord.x) {
return a.coord.x - b.coord.x
}
return a.coord.y - b.coord.y
})

return (
<div className="relative mt-8">
{/* Undo button */}
<button
className="absolute left-4 top-4 rounded bg-red-500 p-2 text-white"
onClick={handleUndo}
>
Undo Move
</button>

{tiles.map((t, i) => (
<Tile
key={i}
Expand All @@ -34,36 +57,3 @@ export function Mahjong() {
</div>
)
}

// function Tile({
// tile,
// open,
// selected,
// onClick,
// }: {
// tile: TileT
// open: boolean
// selected: boolean
// onClick?: () => void
// }) {
// return (
// <div
// className={cn(
// 'h-[40px] w-[20px] border bg-gray-300 text-green-900 translate-x-[calc(var(--x)*20px)] translate-y-[calc(var(--y)*20px)] absolute',
// !open && 'bg-slate-500 text-red-900',
// selected && 'bg-blue-500 text-white',
// )}
// style={{
// '--x': tile.coord.x,
// '--y': tile.coord.y,
// 'zIndex': tile.coord.z,
// } as React.CSSProperties}
// onClick={(e) => {
// e.preventDefault()
// onClick?.()
// }}
// >
// {tile.kind as string}
// </div>
// )
// }
29 changes: 29 additions & 0 deletions frontend/src/components/mahjong/field-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,32 @@ export const TEMPLATE_1: EncodedTemplate = [
[0, 0, 0, 0],
],
]

export const TEMPLATE_2: EncodedTemplate = [
// Layer 1
[
[0, 1, 0, 1, 0],
[1, 2, 0, 2, 1],
[2, 1, 1, 1, 2],
[0, 2, 2, 2, 0],
[0, 0, 0, 0, 0],
],

// Layer 2
[
[0, 0, 0, 0, 0],
[0, 1, 1, 1, 0],
[0, 2, 2, 2, 0],
[1, 1, 1, 1, 1],
[2, 2, 2, 2, 2],
],

// Layer 3
[
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[1, 1, 1, 1, 1],
[2, 2, 2, 2, 2],
[0, 0, 0, 0, 0],
],
]
174 changes: 145 additions & 29 deletions frontend/src/components/mahjong/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,81 @@ import type { FieldTemplate } from './field-template'

export type Coordinate = { x: number, y: number, z: number }
export type Coordinate2D = { x: number, y: number }
export type Move = [Coordinate, Coordinate]
export type Tile<K = unknown> = {
kind: K
export type Move = [Tile, Tile]
export type Tile = {
kind: string
/** Coordinate of the top half of the tile. */
coord: Coordinate
}
export type Comparator<K = unknown> = (kindA: K, kindB: K) => boolean
export type Comparator = (kindA: string, kindB: string) => boolean

export class Game<K = unknown> {
private inGameTiles: Tile<K>[]
private selectedTile: Tile<K> | null
private comparator: Comparator<K>
export class Game {
private inGameTiles: Tile[]
private selectedTile: Tile | null
comparator: Comparator
private moveHistory: Move[]

constructor(
tiles: Tile<K>[],
comparator: Comparator<K>,
public onSelectedTileChange?: (tile: Tile<K> | null) => void,
public onTilesChange?: (tiles: Tile<K>[]) => void,
tiles: Tile[],
comparator: Comparator,
public onSelectedTileChange?: (tile: Tile | null) => void,
public onTilesChange?: (tiles: Tile[]) => void,
) {
this.inGameTiles = tiles
this.selectedTile = null
this.comparator = comparator
this.moveHistory = []
}

public static random<K>(
public static random(
template: FieldTemplate,
comparator: Comparator<K>,
choices: K[],
): Game<K> {
const tiles = template.tileCoords.map(coord => ({
kind: choices[Math.floor(Math.random() * choices.length)],
coord,
}))

return new Game(tiles, comparator)
comparator: Comparator,
kinds: string[],
maxRetries = 100,
): Game {
const availableCoords = [...template.tileCoords]

if (availableCoords.length % 2 !== 0) {
throw new Error(`The number of tile positions (${availableCoords.length}) must be even`)
}

if (availableCoords.length !== kinds.length * 4 && availableCoords.length !== kinds.length * 2) {
throw new Error(`The number of tile positions (${availableCoords.length}) must be equal to the number of choices (${kinds.length}) times 4 or times 2`)
}

for (let retry = 0; retry < maxRetries; retry++) {
const tiles: Tile[] = []
const tilePerKind = availableCoords.length / kinds.length
for (const kind of kinds) {
for (let i = 0; i < tilePerKind; i++) {
tiles.push({ kind, coord: null as any })
}
}
// Shuffle tiles
shuffleArray(tiles)

// Shuffle coordinates
shuffleArray(availableCoords)

// Assign tiles to coordinates
for (let i = 0; i < tiles.length; i++) {
tiles[i].coord = availableCoords[i]
}

// Create Game instance
const game = new Game(tiles, comparator)

// Check solvability
const visited = new Set<string>()
if (isSolvable(game, visited)) {
return game
}
}

throw new Error('Unable to generate a solvable game after maximum retries.')
}

public tiles(): Tile<K>[] {
public tiles(): Tile[] {
return this.inGameTiles
}

Expand All @@ -55,8 +92,8 @@ export class Game<K = unknown> {
return
}
else if (this.comparator(this.selectedTile.kind, tile.kind) && this.isTileOpen(tile.coord)) {
this.removeTile(this.selectedTile.coord)
this.removeTile(tile.coord)
this.removePairTiles(this.selectedTile.coord, tile.coord)
this.moveHistory.push([this.selectedTile, tile]) // Track the move
this.selectedTile = null
this.onSelectedTileChange?.(this.selectedTile)
return
Expand All @@ -69,19 +106,19 @@ export class Game<K = unknown> {
}
}

public removeTile(coord: Coordinate): void {
this.inGameTiles = this.inGameTiles.filter(t => !coordsEqual(t.coord, coord))
public removePairTiles(coord1: Coordinate, coord2: Coordinate): void {
this.inGameTiles = this.inGameTiles.filter(t => !coordsEqual(t.coord, coord1) && !coordsEqual(t.coord, coord2))
this.onTilesChange?.(this.inGameTiles)
}

public tilesAt2D(coord: Coordinate2D): Tile<K>[] {
public tilesAt2D(coord: Coordinate2D): Tile[] {
return this.inGameTiles
.filter(t => t.coord.x === coord.x && (t.coord.y === coord.y || t.coord.y + 1 === coord.y))
.sort((a, b) => b.coord.z - a.coord.z)
}

/** Returns a tile at field coordinate. */
public tileAt({ x, y, z }: Coordinate): Tile<K> | null {
public tileAt({ x, y, z }: Coordinate): Tile | null {
// @todo Make better than O(n)
for (const tile of this.inGameTiles) {
if (tile.coord.z === z && tile.coord.x === x && (tile.coord.y === y || tile.coord.y + 1 === y)) {
Expand Down Expand Up @@ -120,8 +157,87 @@ export class Game<K = unknown> {

return !occupiedLeft || !occupiedRight
}

public lastMove(): Move | null {
return this.moveHistory[this.moveHistory.length - 1] || null
}

public undoLastMove(): void {
if (this.moveHistory.length === 0) {
console.log('No move to undo')
return
}

const [tile1, tile2] = this.moveHistory[this.moveHistory.length - 1]

// Restore the previous state of the game (before the last move)
this.inGameTiles = [...this.inGameTiles, tile1, tile2]
this.moveHistory.pop() // Remove the last move from history
this.onTilesChange?.(this.inGameTiles)
}

public clone(): Game {
return new Game(
this.inGameTiles.map(tile => ({ ...tile, coord: { ...tile.coord } })),
this.comparator,
this.onSelectedTileChange,
this.onTilesChange,
)
}
}

export function coordsEqual(a: Coordinate, b: Coordinate): boolean {
return a.x === b.x && a.y === b.y && a.z === b.z
}

function shuffleArray<T>(array: T[]): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
}

function serializeGame(game: Game): string {
const tileStrings = game
.tiles()
.map(tile => `${tile.kind}:${tile.coord.x},${tile.coord.y},${tile.coord.z}`)
.sort()
return tileStrings.join('|')
}

function isSolvable(game: Game, visited: Set<string>): boolean {
// Base case: if no tiles left, return true
if (game.tiles().length === 0) {
return true
}

const gameState = serializeGame(game)
if (visited.has(gameState)) {
return false
}
visited.add(gameState)

// Get all open tiles
const openTiles = game.tiles().filter(tile => game.isTileOpen(tile.coord))

// For all pairs of open tiles that can be matched
for (let i = 0; i < openTiles.length; i++) {
for (let j = i + 1; j < openTiles.length; j++) {
const tileA = openTiles[i]
const tileB = openTiles[j]

if (game.comparator(tileA.kind, tileB.kind)) {
// Try removing these tiles
const newGame = game.clone()
newGame.removePairTiles(tileA.coord, tileB.coord)

if (isSolvable(newGame, visited)) {
return true
}
}
}
}

// If no moves lead to a solution, return false
return false
}

0 comments on commit b0f7091

Please sign in to comment.