Skip to content

Commit

Permalink
feat(web): handles 3d state while replaying (#167)
Browse files Browse the repository at this point in the history
- fix(server): no redirection to home/lobby closure when owner deletes the current game/lobby.
- fix(web): peer do not apply host game state on game sync.
- fix(web): when receiving game or stream update, aside jumps to the player tab.
- fix(web): peers muted/stopped states are reset when reconnecting to game.
- feat(web): joins a simulation engine to the real engine, to maintain game state while replaying.
- feat(server, web): records hand actions in history.
- refactor(web): binds manager singletons to the 3d engine.
  • Loading branch information
feugy authored Sep 25, 2023
1 parent 907bcc0 commit 680f907
Show file tree
Hide file tree
Showing 133 changed files with 6,049 additions and 4,870 deletions.
5 changes: 2 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# TODO

during replay: host save

## Refactor

- hand: reuse playMeshes() and pickMesh() in handDrag()
Expand All @@ -16,9 +14,10 @@ during replay: host save

## UI

- bug: die do not display the same face in multiplayer (states are correct)
- when hovering target, highlight should have the dragged mesh's shape, not the target shape (what about parts?)
- hand count on peer pointers/player tab?
- score card (Mah-jong, Belote)
- 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
24 changes: 15 additions & 9 deletions apps/server/src/graphql/games-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,18 +239,24 @@ export default {
const subscription = services.gameListsUpdate
.pipe(filter(({ playerId }) => playerId === player.id))
.subscribe(({ games }) => {
const game = games.find(({ id }) => id === gameId)
if (game) {
pubsub.publish({ topic, payload: { receiveGameUpdates: game } })
logger.debug(
{ res: { topic, game: { id: game.id, kind: game.kind } } },
'sent single game update'
)
}
const game = games.find(({ id }) => id === gameId) ?? null
pubsub.publish({ topic, payload: { receiveGameUpdates: game } })
logger.debug(
{
res: {
topic,
game: { id: gameId, kind: game?.kind, removed: !game }
}
},
'sent single game update'
)
})
const queue = await pubsub.subscribe(topic)
queue.once('close', () => subscription.unsubscribe())
logger.debug({ ctx: { topic } }, 'subscribed to single game updates')
logger.debug(
{ ctx: { topic, playerId: player.id } },
'subscribed to single game updates'
)
return queue
}
)
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/graphql/games.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ interface HistoryRecord {
meshId: ID!
playerId: ID!
duration: Int
fromHand: Boolean
}

type PlayerAction implements HistoryRecord {
Expand All @@ -276,6 +277,7 @@ type PlayerAction implements HistoryRecord {
argsStr: String
revertStr: String
duration: Int
fromHand: Boolean
}

type PlayerMove implements HistoryRecord {
Expand All @@ -285,6 +287,7 @@ type PlayerMove implements HistoryRecord {
pos: [Float]!
prev: [Float]!
duration: Int
fromHand: Boolean
}

input GameInput {
Expand Down Expand Up @@ -475,6 +478,7 @@ input HistoryRecordInput {
pos: [Float]
prev: [Float]
duration: Int
fromHand: Boolean
}

type GameParameters {
Expand Down Expand Up @@ -508,5 +512,5 @@ extend type Mutation {

extend type Subscription {
receiveGameListUpdates: [Game!]!
receiveGameUpdates(gameId: ID!): Game!
receiveGameUpdates(gameId: ID!): Game
}
1 change: 1 addition & 0 deletions apps/server/src/services/games.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ import { canAccess } from './catalog.js'
* @property {number} time - when this record happened (timestamp).
* @property {string} playerId - who created this record.
* @property {string} meshId - modified mesh id.
* @property {boolean} fromHand - whether this operation happened in this player's hand.
* @property {number} [duration] - optional animation duration, in milliseconds.
*/

Expand Down
30 changes: 28 additions & 2 deletions apps/server/tests/graphql/games-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -544,14 +544,16 @@ describe('given a started server', () => {
playerId: player.id,
meshId: 'box1',
fn: /** @type {ActionName} */ ('flip'),
argsStr: '[]'
argsStr: '[]',
fromHand: true
},
{
time: Date.now() - 3000,
playerId: player.id,
meshId: 'box1',
pos: [0, 0, 3],
prev: [0, 0, 0]
prev: [0, 0, 0],
fromHand: false
}
],
guestIds: [],
Expand Down Expand Up @@ -585,6 +587,7 @@ describe('given a started server', () => {
time,
playerId,
meshId,
fromHand,
... on PlayerAction {
fn,
argsStr
Expand Down Expand Up @@ -1152,6 +1155,29 @@ describe('given a started server', () => {
expect(services.getPlayerById).toHaveBeenCalledWith(playerId)
expect(services.getPlayerById).toHaveBeenCalledOnce()
})

it('send update on game deletion', async () => {
await startSubscription(
ws,
`subscription {
receiveGameUpdates(gameId: "${games[0].id}") {
id
created
players { id username }
}
}`,
signToken(playerId, configuration.auth.jwt.key)
)
const data = waitOnMessage(ws, data => data.type === 'data')
services.gameListsUpdate.next({ playerId, games: [] })
expect(await data).toEqual(
expect.objectContaining({
payload: { data: { receiveGameUpdates: null } }
})
)
expect(services.getPlayerById).toHaveBeenCalledWith(playerId)
expect(services.getPlayerById).toHaveBeenCalledOnce()
})
})
})
})
48 changes: 21 additions & 27 deletions apps/web/src/3d/behaviors/anchorable.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName
* @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior
* @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails
* @typedef {import('@src/3d/managers/control').Action} Action
* @typedef {import('@src/3d/managers/control').Move} Move
* @typedef {import('@src/3d/managers/move').MoveDetails} MoveDetails
* @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone
*/
Expand All @@ -19,10 +17,6 @@
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'

import { makeLogger } from '../../utils/logger'
import { controlManager } from '../managers/control'
import { indicatorManager } from '../managers/indicator'
import { moveManager } from '../managers/move'
import { selectionManager } from '../managers/selection'
import { actionNames } from '../utils/actions'
import {
animateMove,
Expand All @@ -45,18 +39,19 @@ export class AnchorBehavior extends TargetBehavior {
* Creates behavior to make a mesh anchorable: it has one or several anchors to snap other meshes.
* Each anchor can take up to one mesh only.
* @param {AnchorableState} state - behavior state.
* @param {import('@src/3d/managers').Managers} managers - current managers.
*/
constructor(state = {}) {
super()
constructor(state, managers) {
super({}, managers)
/** @type {RequiredAnchorableState} state - the behavior's current state. */
this.state = /** @type {RequiredAnchorableState} */ (state)
/** @protected @type {?Observer<DropDetails>} */
this.dropObserver = null
/** @protected @type {?Observer<MoveDetails>} */
this.moveObserver = null
/** @protected @type {?Observer<Action|Move>}} */
/** @protected @type {?Observer<import('@src/3d/managers').ActionOrMove>}} */
this.actionObserver = null
/** @internal @type {Map<?, ?>} */
/** @internal @type {Map<string, SingleDropZone>} */
this.zoneBySnappedId = new Map()
}

Expand Down Expand Up @@ -94,22 +89,22 @@ export class AnchorBehavior extends TargetBehavior {
}
)

this.moveObserver = moveManager.onMoveObservable.add(({ mesh }) => {
this.moveObserver = this.managers.move.onMoveObservable.add(({ mesh }) => {
// unsnap the moved mesh, unless:
// 1. it is not snapped!
// 2. it is moved together with the current mesh
if (
this.zoneBySnappedId.has(mesh?.id) &&
!(
selectionManager.meshes.has(mesh) &&
selectionManager.meshes.has(/** @type {Mesh} */ (this.mesh))
this.managers.selection.meshes.has(mesh) &&
this.managers.selection.meshes.has(/** @type {Mesh} */ (this.mesh))
)
) {
this.unsnap(mesh.id)
}
})

this.actionObserver = controlManager.onActionObservable.add(
this.actionObserver = this.managers.control.onActionObservable.add(
async actionOrMove => {
// 1. unsnap all when drawing main mesh
// 2. unsnap drawn snapped mesh
Expand Down Expand Up @@ -149,8 +144,8 @@ export class AnchorBehavior extends TargetBehavior {
* Detaches this behavior from its mesh.
*/
detach() {
controlManager.onActionObservable.remove(this.actionObserver)
moveManager.onMoveObservable.remove(this.moveObserver)
this.managers.control.onActionObservable.remove(this.actionObserver)
this.managers.move.onMoveObservable.remove(this.moveObserver)
this.onDropObservable?.remove(this.dropObserver)
super.detach()
}
Expand All @@ -176,7 +171,7 @@ export class AnchorBehavior extends TargetBehavior {
}

/**
* @returns {String[]} ids for the meshes snapped to this one
* @returns ids for the meshes snapped to this one
*/
getSnappedIds() {
return [...this.zoneBySnappedId.keys()]
Expand Down Expand Up @@ -230,7 +225,7 @@ export class AnchorBehavior extends TargetBehavior {
/**
* Returns the zone to which a given mesh is snapped
* @param {string} meshId - id of the tested mesh
* @returns {?SingleDropZone} zone to which this mesh is snapped, if any
* @returns zone to which this mesh is snapped, if any
*/
snappedZone(meshId) {
return this.zoneBySnappedId.get(meshId) ?? null
Expand Down Expand Up @@ -331,14 +326,14 @@ async function internalSnap(
}
const position = snapped.position.asArray()
const angle = snapped.metadata.angle
indicatorManager.registerFeedback({
behavior.managers.indicator.registerFeedback({
action: actionNames.snap,
position: zone.mesh.absolutePosition.asArray()
})
moveManager.notifyMove(snapped)
behavior.managers.move.notifyMove(snapped)
await snapToAnchor(behavior, snappedId, zone, immediate)
// record after so flippable could flip on demand, after the mesh was snapped.
controlManager.record({
behavior.managers.control.record({
mesh: behavior.mesh,
fn: actionNames.snap,
args: [snappedId, anchorId, immediate],
Expand All @@ -348,7 +343,7 @@ async function internalSnap(
})
const isFlipped = behavior.getZoneFlip(anchorId)
if (isFlipped != undefined && snapped.metadata.isFlipped !== isFlipped) {
await controlManager.invokeLocal(snapped, actionNames.flip)
await behavior.managers.control.invokeLocal(snapped, actionNames.flip)
}
}

Expand Down Expand Up @@ -377,17 +372,17 @@ async function internalUnsnap(
`release snapped ${snappedId} from ${behavior.mesh.id}, zone ${zone.mesh.id}`
)
if (isFlipped != undefined && released.metadata.isFlipped !== isFlipped) {
await controlManager.invokeLocal(released, actionNames.flip)
await behavior.managers.control.invokeLocal(released, actionNames.flip)
}
controlManager.record({
behavior.managers.control.record({
mesh: behavior.mesh,
fn: actionNames.unsnap,
args: [releasedId],
revert: [releasedId, zone.mesh.id],
isLocal
})
unsetAnchor(behavior, zone, released)
indicatorManager.registerFeedback({
behavior.managers.indicator.registerFeedback({
action: actionNames.unsnap,
position: zone.mesh.absolutePosition.asArray()
})
Expand All @@ -401,7 +396,6 @@ async function internalUnsnap(
* @param {string} snappedId - snapped mesh id.
* @param {SingleDropZone} zone - drop zone.
* @param {boolean} [loading=false] - whether the scene is loading.
* @returns {Promise<void>}
*/
async function snapToAnchor(behavior, snappedId, zone, loading = false) {
const {
Expand Down Expand Up @@ -483,7 +477,7 @@ function unsetAnchor(behavior, zone, snapped) {
/**
* @param {Scene|undefined} scene - scene containing meshes.
* @param {string} meshId - searched mesh id.
* @returns {?Mesh[]} list of stacked meshes, if any.
* @returns list of stacked meshes, if any.
*/
function getMeshList(scene, meshId) {
let mesh = scene?.getMeshById(meshId)
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/3d/behaviors/animatable.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ export class AnimateBehavior {
* @param {?Vector3} rotation - its final rotation (set to null to leave unmodified).
* @param {number} duration - move duration (in milliseconds).
* @param {boolean} [gravity=true] - applies gravity at the end.
* @returns {Promise<void>}
*/
async moveTo(to, rotation, duration, gravity = true) {
const { mesh, moveAnimation, rotateAnimation } = this
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/3d/behaviors/detailable.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* @typedef {import('../utils').ScreenPosition} ScreenPosition
*/

import { controlManager } from '../managers/control'
import {
attachFunctions,
attachProperty,
Expand All @@ -18,8 +17,11 @@ export class DetailBehavior {
/**
* Creates behavior to get details of a mesh.
* @param {DetailableState} state - behavior state.
* @param {import('@src/3d/managers').Managers} managers - current managers.
*/
constructor(state = { frontImage: '' }) {
constructor(state, managers) {
/** @internal */
this.managers = managers
/** @type {?Mesh} mesh - the related mesh. */
this.mesh = null
/** @type {DetailableState} state - the behavior's current state. */
Expand Down Expand Up @@ -64,7 +66,7 @@ export class DetailBehavior {
detail() {
if (!this.mesh) return
const stackable = this.mesh.getBehaviorByName(StackBehaviorName)
controlManager.onDetailedObservable.notifyObservers({
this.managers.control.onDetailedObservable.notifyObservers({
position: /** @type {ScreenPosition} */ (
getMeshScreenPosition(this.mesh)
),
Expand Down
Loading

2 comments on commit 680f907

@vercel
Copy link

@vercel vercel bot commented on 680f907 Sep 25, 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.vercel.app
tabulous-feugy.vercel.app
tabulous.fr
tabulous-git-main-feugy.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 680f907 Sep 25, 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.vercel.app
tabulous-atelier-git-main-feugy.vercel.app
tabulous-atelier-feugy.vercel.app

Please sign in to comment.