This commit is contained in:
syuilo 2026-05-27 18:58:50 +09:00
commit 5cefdd224b
9 changed files with 197 additions and 28 deletions

View file

@ -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<ChatEventTypes>;
};
worldRoom: {
name: `worldRoomStream:${string}`;
payload: EventTypesToEventPayload<WorldRoomEventTypes>;
};
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventTypesToEventPayload<ReversiEventTypes>;
@ -435,4 +448,9 @@ export class GlobalEventService {
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishWorldRoomStream<K extends keyof WorldRoomEventTypes>(roomId: string, type: K, value?: WorldRoomEventTypes[K]): void {
this.publish(`worldRoomStream:${roomId}`, type, typeof value === 'undefined' ? null : value);
}
}

View file

@ -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<Record<string, any>> {
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<string, any> = {};
for (const playerId of playerIds) {
const packedUser = packedUsers.find(u => u.id === playerId);
if (packedUser == null) continue;
profiles[playerId] = this.packPlayerProfile(packedUser);
}
return profiles;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -1452,20 +1452,23 @@ export class RoomEngine extends EngineBase<{
this.ev('playSfxUrl', { url, options });
}
public updatePlayers(profiles: Record<string, PlayerProfile>, states: Record<string, PlayerState>) {
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
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<string, PlayerState>) {
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) {

View file

@ -286,7 +286,7 @@ const roomControllerOptions = computed<RoomControllerOptions>(() => ({
}));
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() {

View file

@ -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<RoomEngine> {
public standUp() {
this.call('standUp');
}
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
this.call('updatePlayerProfiles', [profiles]);
}
public updatePlayerStates(states: Record<string, PlayerState>) {
this.call('updatePlayerStates', [states]);
}
public clearPlayers() {
this.call('clearPlayers');
}
}

View file

@ -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<Misskey.Channels['worldRoom']> | null = null;
private roomId: string;
private playerProfiles: Record<string, PlayerProfile> = {};
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<string, PlayerState>) {
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();
}
}

View file

@ -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;