diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 5fe50e5e64..8a92ae88f9 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -173,6 +173,15 @@ export interface ChatEventTypes { }; } +export interface WorldRoomEventTypes { + enter: { + user: Packed<'UserLite'>; + }; + left: { + userId: MiUser['id']; + }; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -315,6 +324,10 @@ export type GlobalEvents = { name: `chatRoomStream:${MiChatRoom['id']}`; payload: EventTypesToEventPayload; }; + worldRoom: { + name: `worldRoomStream:${string}`; + payload: EventTypesToEventPayload; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload; @@ -435,4 +448,9 @@ export class GlobalEventService { public publishReversiGameStream(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void { this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value); } + + @bindThis + public publishWorldRoomStream(roomId: string, type: K, value?: WorldRoomEventTypes[K]): void { + this.publish(`worldRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value); + } } diff --git a/packages/backend/src/core/WorldRoomMultiplayService.ts b/packages/backend/src/core/WorldRoomMultiplayService.ts index f7e855030e..0754ab0ef5 100644 --- a/packages/backend/src/core/WorldRoomMultiplayService.ts +++ b/packages/backend/src/core/WorldRoomMultiplayService.ts @@ -17,6 +17,9 @@ import { IdService } from '@/core/IdService.js'; import type { MiUser } from '@/models/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueryService } from '@/core/QueryService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; type PlayerState = { position: [number, number, number], @@ -39,11 +42,15 @@ export class WorldRoomMultiplayService { private roleService: RoleService, private queryService: QueryService, private idService: IdService, + private globalEventService: GlobalEventService, + private userEntityService: UserEntityService, ) { } @bindThis public async enter(userId: MiUser['id'], roomId: MiWorldRoom['id']) { + console.log('enter', { userId, roomId }); + // TODO: atomicにやる const currentPlayers = await this.redisClient.hlen(`worldRoom:${roomId}:players`); if (currentPlayers < 10) { @@ -54,6 +61,11 @@ export class WorldRoomMultiplayService { } else { throw new Error('Room is full.'); } + + // TODO: 既に入っていたらスキップ + this.globalEventService.publishWorldRoomStream(roomId, 'enter', { + user: await this.userEntityService.pack(userId), + }); } @bindThis @@ -70,11 +82,17 @@ export class WorldRoomMultiplayService { } @bindThis - public async leave(userId: MiUser['id'], roomId: MiWorldRoom['id']) { + public async left(userId: MiUser['id'], roomId: MiWorldRoom['id']) { + console.log('left', { userId, roomId }); + const redisPipeline = this.redisClient.pipeline(); redisPipeline.hdel(`worldRoom:${roomId}:players`, userId); redisPipeline.hdel(`worldRoom:${roomId}:playerStates`, userId); await redisPipeline.exec(); + + this.globalEventService.publishWorldRoomStream(roomId, 'left', { + userId, + }); } @bindThis @@ -97,4 +115,29 @@ export class WorldRoomMultiplayService { this.heartbeat(userId, roomId); return this.getPlayerStates(roomId); } + + @bindThis + public packPlayerProfile(user: Packed<'UserLite'>) { + return { + name: user.name, + username: user.username, + avatarUrl: user.avatarUrl, + }; + } + + @bindThis + public async getPlayerProfiles(roomId: MiWorldRoom['id'], userId?: MiUser['id']): Promise> { + let playerIds = await this.redisClient.hkeys(`worldRoom:${roomId}:players`); + playerIds = playerIds.filter(id => id !== userId); + + const packedUsers = await this.userEntityService.packMany(playerIds); + + const profiles: Record = {}; + for (const playerId of playerIds) { + const packedUser = packedUsers.find(u => u.id === playerId); + if (packedUser == null) continue; + profiles[playerId] = this.packPlayerProfile(packedUser); + } + return profiles; + } } diff --git a/packages/backend/src/server/api/stream/channels/world-room.ts b/packages/backend/src/server/api/stream/channels/world-room.ts index 8f414c61e3..4daaadbec8 100644 --- a/packages/backend/src/server/api/stream/channels/world-room.ts +++ b/packages/backend/src/server/api/stream/channels/world-room.ts @@ -49,7 +49,7 @@ export class WorldRoomChannel extends Channel { return false; } - //this.subscriber.on(`worldRoomStream:${this.roomId}`, this.onEvent); + this.subscriber.on(`worldRoomStream:${this.roomId}`, this.onEvent); return true; } @@ -62,19 +62,37 @@ export class WorldRoomChannel extends Channel { this.isEntered = true; - this.send('entered', {}); + this.send('entered', { + playerProfiles: await this.worldRoomMultiplayService.getPlayerProfiles(this.roomId, this.user!.id), + }); this.intervalId = setInterval(async () => { const states = await this.worldRoomMultiplayService.getPlayerStatesAndHeatbeat(this.user!.id, this.roomId); - // TODO: 自分自身のstateは抜く + delete states[this.user!.id]; this.send('sync', states); - }, 1000); + }, 100); } - //@bindThis - //private async onEvent(data: GlobalEvents['worldRoom']['payload']) { - // this.send(data.type, data.body); - //} + @bindThis + private async onEvent(data: GlobalEvents['worldRoom']['payload']) { + switch (data.type) { + case 'enter': { + if (data.body.user.id === this.user!.id) return; // 自分の入室は無視 + this.send('playerEntered', { + id: data.body.user.id, + profile: this.worldRoomMultiplayService.packPlayerProfile(data.body.user.id), + }); + break; + } + case 'left': { + if (data.body.userId === this.user!.id) return; // 自分の退室は無視 + this.send('playerLeft', { + id: data.body.userId, + }); + break; + } + } + } @bindThis public onMessage(type: string, body: any) { @@ -89,9 +107,9 @@ export class WorldRoomChannel extends Channel { @bindThis public dispose() { - //this.subscriber.off(`worldRoomStream:${this.roomId}`, this.onEvent); + this.subscriber.off(`worldRoomStream:${this.roomId}`, this.onEvent); clearInterval(this.intervalId); - this.worldRoomMultiplayService.leave(this.user!.id, this.roomId); + this.worldRoomMultiplayService.left(this.user!.id, this.roomId); } } diff --git a/packages/frontend-misskey-world-engine/src/PlayerContainer.ts b/packages/frontend-misskey-world-engine/src/PlayerContainer.ts index b7ef9a9f0e..ab4031deec 100644 --- a/packages/frontend-misskey-world-engine/src/PlayerContainer.ts +++ b/packages/frontend-misskey-world-engine/src/PlayerContainer.ts @@ -8,6 +8,7 @@ import { WORLD_SCALE } from 'misskey-world/src/utility.js'; export type PlayerProfile = { name: string; + username: string; avatarUrl: string; }; @@ -65,7 +66,8 @@ export class PlayerContainer { this.root.position.set(...state.position); this.root.rotation.set(...state.rotation); if (!forInit) { - this.sr.updateMesh(this.root.getChildMeshes()); + const meshes = this.root.getChildMeshes(); + if (meshes.length > 0) this.sr.updateMesh(meshes); } } diff --git a/packages/frontend-misskey-world-engine/src/room/engine.ts b/packages/frontend-misskey-world-engine/src/room/engine.ts index 4140e54781..a6426539ee 100644 --- a/packages/frontend-misskey-world-engine/src/room/engine.ts +++ b/packages/frontend-misskey-world-engine/src/room/engine.ts @@ -1452,20 +1452,23 @@ export class RoomEngine extends EngineBase<{ this.ev('playSfxUrl', { url, options }); } - public updatePlayers(profiles: Record, states: Record) { + public updatePlayerProfiles(profiles: Record) { + this.playerProfiles = profiles; + for (const playerContainer of this.playerContainers) { - if (profiles[playerContainer.id] == null) { + if (this.playerProfiles[playerContainer.id] == null) { this.sr.disableSnapshotRendering(); playerContainer.destroy(); this.sr.enableSnapshotRendering(); } } - this.playerContainers = this.playerContainers.filter(p => profiles[p.id] != null); + this.playerContainers = this.playerContainers.filter(p => this.playerProfiles[p.id] != null); + } - for (const [k, v] of Object.entries(profiles)) { + public updatePlayerStates(states: Record) { + for (const [k, v] of Object.entries(this.playerProfiles)) { const playerContainer = this.playerContainers.find(p => p.id === k); if (playerContainer == null) { - this.sr.disableSnapshotRendering(); const p = new PlayerContainer({ id: k, profile: v, @@ -1473,7 +1476,56 @@ export class RoomEngine extends EngineBase<{ scene: this.scene, sr: this.sr, }); - this.sr.enableSnapshotRendering(); + // TODO: loadObjectのものとある程度共通化 + p.registerMeshes = (meshes) => { + for (const mesh of meshes) { + if (SYSTEM_MESH_NAMES.some(n => mesh.name.includes(n))) { + mesh.receiveShadows = false; + mesh.isVisible = false; + } else { + mesh.receiveShadows = true; + // TODO: メモリリークしそうだからいい感じにする + this.envManager.addShadowCaster(mesh); + + //if (mesh.material) (mesh.material as BABYLON.PBRMaterial).ambientColor = new BABYLON.Color3(0.2, 0.2, 0.2); + if (mesh.material) { + if (mesh.material instanceof BABYLON.MultiMaterial) { + for (const subMat of mesh.material.subMaterials) { + if ((subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) { + (subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する + (subMat as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND; + (subMat as BABYLON.PBRMaterial).alpha = 0.5; + (subMat as BABYLON.PBRMaterial).metallic = 1; + } + (subMat as BABYLON.PBRMaterial).reflectionTexture = this.envManager?.envMapIndoor; + if ((subMat as BABYLON.PBRMaterial).metadata == null) (subMat as BABYLON.PBRMaterial).metadata = {}; + (subMat as BABYLON.PBRMaterial).metadata.useEnvMapAsObjectMaterial = true; + (subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts + (subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため + } + } else { + if ((mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) { + (mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する + (mesh.material as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND; + (mesh.material as BABYLON.PBRMaterial).alpha = 0.5; + (mesh.material as BABYLON.PBRMaterial).metallic = 1; + } + (mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envManager?.envMapIndoor; + if ((mesh.material as BABYLON.PBRMaterial).metadata == null) (mesh.material as BABYLON.PBRMaterial).metadata = {}; + (mesh.material as BABYLON.PBRMaterial).metadata.useEnvMapAsObjectMaterial = true; + (mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts + (mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため + } + } + } + + if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh); + } + }; + p.loadAvatar().then(() => { + this.sr.disableSnapshotRendering(); + this.sr.enableSnapshotRendering(); + }); this.playerContainers.push(p); } else { if (states[k] != null) { diff --git a/packages/frontend/src/pages/rooms/room.core.vue b/packages/frontend/src/pages/rooms/room.core.vue index d452b25aa0..0410c9ff9b 100644 --- a/packages/frontend/src/pages/rooms/room.core.vue +++ b/packages/frontend/src/pages/rooms/room.core.vue @@ -286,7 +286,7 @@ const roomControllerOptions = computed(() => ({ })); const controller = markRaw(new RoomController(deepClone(initialRoomState), roomControllerOptions.value)); -const multiplayer = markRaw(new Multiplayer(props.room.id)); +const multiplayer = markRaw(new Multiplayer(props.room.id, controller)); onMounted(async () => { // TODO: babylonに依存しないで判定する @@ -349,7 +349,7 @@ onMounted(async () => { useInterval(() => { multiplayer.updateState(controller.myPlayerState.value); -}, 1000, { immediate: false, afterMounted: true }); +}, 100, { immediate: false, afterMounted: true }); onDeactivated(() => { controller.destroy(); @@ -657,7 +657,7 @@ function showOtherMenu(ev: PointerEvent) { } function leaveOnline() { - multiplayer.leave(); + multiplayer.left(); } function enterOnline() { diff --git a/packages/frontend/src/world/room/controller.ts b/packages/frontend/src/world/room/controller.ts index ef4164c166..e20cb4b9d1 100644 --- a/packages/frontend/src/world/room/controller.ts +++ b/packages/frontend/src/world/room/controller.ts @@ -10,7 +10,7 @@ import type { ShallowRef } from 'vue'; import type { RoomStateObject } from 'misskey-world/src/room/object.js'; import type { RoomEngine } from 'misskey-world-engine/src/room/engine.js'; import type { RoomAttachments, RoomState } from 'misskey-world/src/room/type.js'; -import type { PlayerState } from 'misskey-world-engine/src/PlayerContainer.js'; +import type { PlayerProfile, PlayerState } from 'misskey-world-engine/src/PlayerContainer.js'; import * as sound from '@/utility/sound.js'; import { deepEqual } from '@/utility/deep-equal.js'; import { deepClone } from '@/utility/clone.js'; @@ -222,4 +222,16 @@ export class RoomController extends EngineControllerBase { public standUp() { this.call('standUp'); } + + public updatePlayerProfiles(profiles: Record) { + this.call('updatePlayerProfiles', [profiles]); + } + + public updatePlayerStates(states: Record) { + this.call('updatePlayerStates', [states]); + } + + public clearPlayers() { + this.call('clearPlayers'); + } } diff --git a/packages/frontend/src/world/room/multiplayer.ts b/packages/frontend/src/world/room/multiplayer.ts index 64cc785518..4bf67251dc 100644 --- a/packages/frontend/src/world/room/multiplayer.ts +++ b/packages/frontend/src/world/room/multiplayer.ts @@ -6,7 +6,8 @@ import { reactive, ref, shallowRef, triggerRef, watch } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; -import type { PlayerState } from 'misskey-world-engine/src/PlayerContainer.js'; +import type { PlayerProfile, PlayerState } from 'misskey-world-engine/src/PlayerContainer.js'; +import type { RoomController } from './controller.js'; import { useStream } from '@/stream.js'; import * as os from '@/os.js'; import { withTimeout } from '@/utility/promise-timeout.js'; @@ -14,13 +15,18 @@ import { deepEqual } from '@/utility/deep-equal.js'; export class Multiplayer { public isOnline = ref(false); + private controller: RoomController; private connection: Misskey.IChannelConnection | null = null; private roomId: string; + private playerProfiles: Record = {}; - constructor(roomId: string) { + constructor(roomId: string, controller: RoomController) { this.roomId = roomId; + this.controller = controller; this.onSync = this.onSync.bind(this); + this.onPlayerEntered = this.onPlayerEntered.bind(this); + this.onPlayerLeft = this.onPlayerLeft.bind(this); } public enter() { @@ -28,8 +34,13 @@ export class Multiplayer { this.connection = useStream().useChannel('worldRoom', { roomId: this.roomId, }); - this.connection.once('entered', () => { + this.connection.once('entered', ({ playerProfiles }) => { + console.log('entered', playerProfiles); + this.playerProfiles = playerProfiles; + this.controller.updatePlayerProfiles(this.playerProfiles); this.connection!.on('sync', this.onSync); + this.connection!.on('playerEntered', this.onPlayerEntered); + this.connection!.on('playerLeft', this.onPlayerLeft); this.isOnline.value = true; resolve(); }); @@ -42,7 +53,7 @@ export class Multiplayer { }); } - public leave() { + public left() { if (this.connection == null) return; this.connection.dispose(); this.connection = null; @@ -61,9 +72,20 @@ export class Multiplayer { private onSync(states: Record) { console.log('sync', states); + this.controller.updatePlayerStates(states); + } + + private onPlayerEntered(data: { id: string; profile: PlayerProfile; }) { + this.playerProfiles[data.id] = data.profile; + this.controller.updatePlayerProfiles(this.playerProfiles); + } + + private onPlayerLeft(data: { id: string; }) { + delete this.playerProfiles[data.id]; + this.controller.updatePlayerProfiles(this.playerProfiles); } public dispose() { - this.leave(); + this.left(); } } diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 0711cee084..2b3bd95500 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -295,8 +295,10 @@ export type Channels = { roomId: string; }; events: { - entered: () => void; + entered: (payload: { playerProfiles: any; }) => void; sync: (payload: any) => void; + playerEntered: (payload: { id: string; profile: any; }) => void; + playerLeft: (payload: { id: string; }) => void; }; receives: { update: any;