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

Commit

Permalink
some working mahjong
Browse files Browse the repository at this point in the history
  • Loading branch information
evermake committed Nov 29, 2024
1 parent bd54b27 commit c5d39fc
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 9 deletions.
5 changes: 5 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ export default antfu(
plugins: [],
},
...tailwind.configs['flat/recommended'],
{
rules: {
'ts/consistent-type-definitions': 'off',
},
},
)
69 changes: 69 additions & 0 deletions frontend/src/components/mahjong/Mahjong.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Tile as TileT } from './game'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { FieldTemplate, TEMPLATE_1 } from './field-template'
import { coordsEqual, Game } from './game'

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

const [game, _] = useState(() => {
const g = Game.random(
FieldTemplate.decode(TEMPLATE_1),
(a, b) => a === b,
['A', 'B', 'C', 'D'],
)
g.onTilesChange = setTiles
g.onSelectedTileChange = setSelected
setTiles(g.tiles())
return g
})

return (
<div className="relative">
{tiles.map((t, i) => (
<Tile
key={i}
tile={t}
open={game.isTileOpen(t.coord)}
selected={selected ? coordsEqual(t.coord, selected.coord) : false}
onClick={() => game.selectAt(t.coord)}
/>
))}
</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>
)
}
51 changes: 49 additions & 2 deletions frontend/src/components/mahjong/field-template.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Coordinate } from './game'

/**
* Field template is encoded as a list of 2D matrixes, where each matrix
* represents a layer of the field.
Expand Down Expand Up @@ -28,10 +30,55 @@
* ]
* ```
*/
export type EncodedTemplate = number[][]
export type EncodedTemplate = number[][][]

export class FieldTemplate {
tileCoords: Coordinate[]

constructor(tileCoords: Coordinate[]) {
this.tileCoords = tileCoords
}

static decode(encoded: EncodedTemplate): FieldTemplate {
// @todo
const tileCoords: Coordinate[] = []

for (let z = 0; z < encoded.length; z++) {
for (let y = 0; y < encoded[z].length; y++) {
for (let x = 0; x < encoded[z][y].length; x++) {
switch (encoded[z][y][x]) {
case 0: break
case 1: { // Make sure below is 2 and add the tile.
if (encoded[z][y + 1]?.[x] !== 2)
throw new Error('Invalid template: 1 without 2 below')
tileCoords.push({ x, y, z: z + 1 })
break
}
case 2: { // Make sure above is 1.
if (encoded[z][y - 1]?.[x] !== 1)
throw new Error('Invalid template: 2 without 1 above')
break
}
}
}
}
}

return new FieldTemplate(tileCoords)
}
}

export const TEMPLATE_1: EncodedTemplate = [
[
[0, 1, 1, 0],
[1, 2, 2, 1],
[2, 1, 1, 2],
[0, 2, 2, 0],
],

[
[0, 0, 0, 0],
[0, 1, 1, 0],
[0, 2, 2, 0],
[0, 0, 0, 0],
],
]
3 changes: 0 additions & 3 deletions frontend/src/components/mahjong/field.ts

This file was deleted.

126 changes: 126 additions & 0 deletions frontend/src/components/mahjong/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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
/** Coordinate of the top half of the tile. */
coord: Coordinate
}
export type Comparator<K = unknown> = (kindA: K, kindB: K) => boolean

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

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

public static random<K>(
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)
}

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

public selectAt(coord: Coordinate2D): void {
const tile = this.tilesAt2D(coord)[0]
if (!tile)
return

if (this.selectedTile) {
if (coordsEqual(this.selectedTile.coord, tile.coord)) {
this.selectedTile = null
return
}
else if (this.comparator(this.selectedTile.kind, tile.kind) && this.isTileOpen(tile.coord)) {
this.removeTile(this.selectedTile.coord)
this.removeTile(tile.coord)
this.selectedTile = null
return
}
}

if (this.isTileOpen(tile.coord)) {
this.selectedTile = tile
}

this.onSelectedTileChange?.(this.selectedTile)
}

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

public tilesAt2D(coord: Coordinate2D): Tile<K>[] {
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 {
// @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)) {
return tile
}
}
return null
}

/** Returns whether the tile at the given field coordinate is open. */
public isTileOpen({ x, y, z }: Coordinate): boolean {
let occupiedLeft = false
let occupiedRight = false

// @todo Make better than O(n)
for (const tile of this.inGameTiles) {
if (tile.coord.z < z)
continue

if (tile.coord.z > z) {
// If lies on top -> closed
if (tile.coord.x === x && (Math.abs(tile.coord.y - y) <= 1)) {
return false
}
}

if (tile.coord.z === z) {
if (tile.coord.x - 1 === x && (Math.abs(tile.coord.y - y) <= 1)) {
occupiedLeft = true
}
else if (tile.coord.x + 1 === x && (Math.abs(tile.coord.y - y) <= 1)) {
occupiedRight = true
}
}
}

return !occupiedLeft || !occupiedRight
}
}

export function coordsEqual(a: Coordinate, b: Coordinate): boolean {
return a.x === b.x && a.y === b.y && a.z === b.z
}
31 changes: 27 additions & 4 deletions frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@
// Import Routes

import { Route as rootRoute } from './routes/__root'
import { Route as TestImport } from './routes/test'
import { Route as IndexImport } from './routes/index'

// Create/Update Routes

const TestRoute = TestImport.update({
id: '/test',
path: '/test',
getParentRoute: () => rootRoute,
} as any)

const IndexRoute = IndexImport.update({
id: '/',
path: '/',
Expand All @@ -32,39 +39,51 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/test': {
id: '/test'
path: '/test'
fullPath: '/test'
preLoaderRoute: typeof TestImport
parentRoute: typeof rootRoute
}
}
}

// Create and export the route tree

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/test': typeof TestRoute
}

export interface FileRoutesByTo {
'/': typeof IndexRoute
'/test': typeof TestRoute
}

export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/test': typeof TestRoute
}

export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/test'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/test'
id: '__root__' | '/' | '/test'
fileRoutesById: FileRoutesById
}

export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
TestRoute: typeof TestRoute
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
TestRoute: TestRoute,
}

export const routeTree = rootRoute
Expand All @@ -77,11 +96,15 @@ export const routeTree = rootRoute
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/test"
]
},
"/": {
"filePath": "index.tsx"
},
"/test": {
"filePath": "test.tsx"
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/routes/test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Mahjong } from '@/components/mahjong/Mahjong'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/test')({
component: RouteComponent,
})

function RouteComponent() {
return (
<div className="flex h-screen items-center justify-center">
<Mahjong />
</div>
)
}

0 comments on commit c5d39fc

Please sign in to comment.