Skip to content

Commit

Permalink
feat(web): introduce score tracking (#169)
Browse files Browse the repository at this point in the history
- fix(web): anchorable flip is ignore when loading game.
- fix(web): lost meshes when decrementing a quantifiable.
- fix(web): game loading screen hides game aside tabs.
- feat(server)!: GraphQL game queries now return serialized preferences.
- feat(server): bundles game descriptors on server start with esbuild.
- feat(server): returns bundled rule engine when promoting and joining a game.
- feat(server): supports multiple anchors and migrate existing games.
- feat(web): initializes rule engine with bundled scripts and computes score on game init, joined player, and every action.
- feat(web): displays game scores, loads serialized preferences, provides preferences to `computeScore()`.
- feat(web): supports anchors with multiple snapped meshes.
- feat(game-utils): supports multiple anchors.
- feat(game-utils): allows testing random game code.
- feat(mah-jong): supports multiple anchors and computes score.
- feat(draughts): supports multiple anchors and computes score.
- feat(klondike): supports multiple anchors.
- feat(chess): supports multiple anchors.
- feat(playground): supports multiple anchors.
- chore(server): configures repositories `isProduction` option.
- refactor(web): moves some types to `@tabulous/types`.
- refactor(game-utils): takes `findPlayerPreferences()` from web.
  • Loading branch information
feugy committed Oct 14, 2023
1 parent 7114ffe commit b790f19
Show file tree
Hide file tree
Showing 112 changed files with 6,000 additions and 1,793 deletions.
4 changes: 1 addition & 3 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ jobs:

- name: Intall Pnpm
uses: pnpm/action-setup@v2
with:
version: 7

- name: Set Node.js up
uses: actions/setup-node@v3
Expand Down Expand Up @@ -241,4 +239,4 @@ jobs:
uses: codacy/codacy-coverage-reporter-action@master
with:
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
coverage-reports: apps/cli/coverage/clover.xml
coverage-reports: apps/games/coverage/clover.xml
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ test-results
.vercel
.last-db
upload-manually.sh
en
en
engine.min.js
16 changes: 13 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

## Refactor

- Control manager: trigger rule when action is finished?
- hand: reuse playMeshes() and pickMesh() in handDrag()
- replace windicss with a successor (UnoCSS)
- add tests for web/src/utils/peer-connection
- add tests for web/src/utils/peer-connection + web/src/stores/graphql
- group candidate target per kind for performance
- all manager managing a collection of behaviors should check their capabilities
- stackable/anchorable should check the capabilities of stacked/anchored meshes
Expand All @@ -14,9 +15,10 @@

## UI

- when hovering target, highlight should have the dragged mesh's shape, not the target shape (what about parts?)
- fix(web): indicators attach point does not consider the camera position.
- when hovering target, highlight could have the dragged mesh's shape, not the target shape (what about parts?)
- improve selection accuracy, especially with cylindric meshes
- hand count on peer pointers/player tab?
- score (Mah-jong, Belote)
- command to reset some mesh state and restart a game (Mah-jong, Belote)
- "box" space for unusued/undesired meshes
- hide/distinguish non-connected participants?
Expand Down Expand Up @@ -212,3 +214,11 @@ For choosing game colors:
Besides being hard to debug and troubleshoot, it requires many encoding/decoding operations since GraphQL only allows text,
discards any database migrations or server-side operations (unless rebuilding the document on server-side), and the overall effort of
refactoring all communciation pipes and storage code does not really worth it.

Finite State Machines (FSM) could be a good fit for game rules. Chess for example: a global FSM for game, one for each player, one for each piece.
Transition are possible moves, all relative to the piece's position and direction. FSM could be used to restict action menu items, highlight possible location when starting moving, or revert invalid moves after they are made.
[XState](https://xstate.js.org/docs/) could match (what about the performance?), especially because of designing and debugging tools.

Good [read](https://gameprogrammingpatterns.com)

TypeScript compiler's `checkJS` is the root of evil. It checks files in transitive dependencies we have no control on, and that are extremely hard to trace and exclude. Selective opt-in with `@ts-check` is annoying but yields much better results.
26 changes: 18 additions & 8 deletions apps/game-utils/src/descriptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { mergeProps } from './utils.js'

/**
* Creates a unique game from a game descriptor.
* @template {Record<string, ?>} Parameters
* @param {string} kind - created game's kind.
* @param {Partial<import('@tabulous/types').GameDescriptor>} descriptor - to create game from.
* @param {Partial<import('@tabulous/types').GameDescriptor<Parameters>>} descriptor - to create game from.
* @returns a list of serialized 3D meshes.
*/
export async function createMeshes(kind, descriptor) {
Expand Down Expand Up @@ -122,6 +123,10 @@ function randomizeBags(bags, meshById) {
}

/**
* Picks in the bag as many random mesh as requested by the slot (defaults to all).
* Then snaps as many meshes as possible to the slot's anchor (according to its max), and
* stacks the remaining meshes on top of the first one.
* When snapping on multiple anchors, meshes are NOT layed out.
* @param {import('@tabulous/types').Slot} slot - slot to fill.
* @param {Map<string, import('@tabulous/types').Mesh[]>} meshesByBagId - randomized meshes per bags.
* @param {import('@tabulous/types').Mesh[]} allMeshes - all meshes
Expand All @@ -134,23 +139,28 @@ function fillSlot(
const candidates = meshesByBagId.get(bagId)
if (candidates?.length) {
const meshes = candidates.splice(0, count ?? candidates.length)
/** @type {import('@tabulous/types').Mesh[]} */
let stack = meshes
for (const mesh of meshes) {
mergeProps(mesh, props)
}
if (anchorId) {
const anchor = findAnchor(anchorId, allMeshes)
if (anchor) {
if (anchor.snappedId) {
const mesh = findMesh(anchor.snappedId, allMeshes)
if (mesh) {
meshes.splice(0, 0, mesh)
const snapped = findMesh(anchor.snappedIds[0], allMeshes, false)
stack = snapped ? [snapped] : []
for (const mesh of meshes) {
if (anchor.snappedIds.length === (anchor.max ?? 1)) {
stack.push(mesh)
} else {
anchor.snappedIds.push(mesh.id)
stack = [mesh]
// TODO: lay out multiple meshes
}
} else {
anchor.snappedId = meshes[0].id
}
}
}
stackMeshes(meshes)
stackMeshes(stack)
}
}

Expand Down
10 changes: 6 additions & 4 deletions apps/game-utils/src/hand.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,25 @@ export function drawInHand(
const hand = findOrCreateHand(game, playerId)
const meshes = game.meshes
const anchor = findAnchor(fromAnchor, meshes)
const stack = findMesh(anchor.snappedId, meshes, false)
const stack = findMesh(anchor.snappedIds[0], meshes, false)
if (!stack) {
throw new Error(`Anchor ${fromAnchor} has no snapped mesh`)
}
for (let i = 0; i < count; i++) {
/** @type {import('@tabulous/types').Mesh} */
const drawn =
stack.stackable?.stackIds?.length === 0 ? stack : popMesh(stack, meshes)
(stack.stackable?.stackIds?.length ?? 0) === 0
? stack
: popMesh(stack, meshes)
mergeProps(drawn, props)
hand.meshes.push(drawn)
meshes.splice(meshes.indexOf(drawn), 1)
if (drawn === stack) {
break
}
}
if (stack.stackable?.stackIds?.length === 0) {
anchor.snappedId = null
if ((stack.stackable?.stackIds?.length ?? 0) === 0) {
anchor.snappedIds.splice(0, 1)
}
}

Expand Down
15 changes: 7 additions & 8 deletions apps/game-utils/src/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function findAnchor(
if (!match) {
return null
}
candidates = meshes.filter(({ id }) => id === match.anchor.snappedId)
candidates = meshes.filter(({ id }) => match.anchor.snappedIds.includes(id))
anchor = match.anchor
}
return anchor ?? null
Expand Down Expand Up @@ -188,14 +188,14 @@ export function snapTo(
}
return false
}
if (anchor.snappedId) {
const snapped = findMesh(anchor.snappedId, meshes, throwOnMiss)
if (anchor.snappedIds.length === (anchor.max ?? 1)) {
const snapped = findMesh(anchor.snappedIds[0], meshes, throwOnMiss)
if (!canStack(snapped, mesh)) {
return false
}
stackMeshes([snapped, mesh])
} else {
anchor.snappedId = mesh.id
anchor.snappedIds.push(mesh.id)
}
return true
}
Expand All @@ -206,7 +206,7 @@ export function snapTo(
* @param {string} anchorId - desired anchor id.
* @param {import('@tabulous/types').Mesh[]} meshes - all meshes to search the anchor in.
* @param {boolean} [throwOnMiss=true]
* @returns {?import('@tabulous/types').Mesh} unsnapped meshes, or null if anchor has no snapped mesh
* @returns {import('@tabulous/types').Mesh} unsnapped meshes, or null if anchor has no snapped mesh
* @throws {Error} when anchor (or snapped mesh) could not be found.
*
* @overload
Expand All @@ -222,14 +222,13 @@ export function unsnap(
throwOnMiss = true
) {
const anchor = findAnchor(anchorId, meshes, throwOnMiss)
if (!anchor || !anchor.snappedId) {
if (!anchor || anchor.snappedIds.length === 0) {
if (throwOnMiss) {
throw new Error(`Anchor ${anchorId} has no snapped mesh`)
}
return null
}
const id = anchor.snappedId
anchor.snappedId = null
const [id] = anchor.snappedIds.splice(0, 1)
return findMesh(id, meshes, throwOnMiss)
}

Expand Down
18 changes: 18 additions & 0 deletions apps/game-utils/src/preference.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,21 @@ export function findAvailableValues(preferences, name, possibleValues) {
preferences.every(pref => value !== pref[name])
)
}

/**
* Find game preferences of a given player.
* @param {import('@tabulous/types').PlayerPreference[]|undefined} preferences - list of all players preferences.
* @param {string} playerId - desired player
* @returns found preferences, or an empty object.
*/
export function findPlayerPreferences(preferences, playerId) {
/** @type {Omit<import('@tabulous/types').PlayerPreference, 'playerId'>} */
const preference = {
...(preferences?.find(preference => preference.playerId === playerId) ?? {
color: undefined,
angle: undefined
})
}
delete preference.playerId
return preference
}

2 comments on commit b790f19

@vercel
Copy link

@vercel vercel bot commented on b790f19 Oct 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

tabulous-atelier – ./apps/web

tabulous-atelier-feugy.vercel.app
tabulous-atelier.vercel.app
tabulous-atelier-git-main-feugy.vercel.app

@vercel
Copy link

@vercel vercel bot commented on b790f19 Oct 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

tabulous – ./apps/web

tabulous-git-main-feugy.vercel.app
tabulous-feugy.vercel.app
tabulous.vercel.app
tabulous.fr

Please sign in to comment.