This commit is contained in:
syuilo 2026-05-28 13:12:35 +09:00
commit a0aa64cd9f
26 changed files with 258 additions and 58 deletions

View file

@ -3570,6 +3570,12 @@ _miWorld:
antialiasing: "アンチエイリアス"
avatar: "アバター"
_avatars:
_default:
body: "ボディ"
eyes: "目"
mouth: "口"
_miRoom:
snapToGrid: "グリッドにスナップ"
gridScale: "グリッドサイズ"

View file

@ -78,5 +78,16 @@ export class WorldAvatarEntityService {
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packLite(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
@bindThis
public async packDetailedMany(
avatars: MiWorldAvatar[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = avatars.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packDetailed(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
}

View file

@ -24,7 +24,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarLite',
ref: 'WorldAvatarDetailed',
},
},
@ -41,7 +41,7 @@ export const paramDef = {
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: ['userId'],
required: [],
} as const;
@Injectable()
@ -55,8 +55,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(ps.userId, ps.limit, sinceId, untilId);
return this.worldAvatarEntityService.packLiteMany(avatars, me);
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(me.id, ps.limit, sinceId, untilId);
return this.worldAvatarEntityService.packDetailedMany(avatars, me);
});
}
}

View file

@ -20,6 +20,27 @@ export type PlayerState = {
sit?: string; // id
};
const DEFAULT_FACE_PARTS_EYES = {
'_none_': null,
'a': '/client-assets/world/avatars/eyes-a.png',
'b': '/client-assets/world/avatars/eyes-b.png',
'c': '/client-assets/world/avatars/eyes-c.png',
'd': '/client-assets/world/avatars/eyes-d.png',
'e': '/client-assets/world/avatars/eyes-e.png',
'f': '/client-assets/world/avatars/eyes-f.png',
'g': '/client-assets/world/avatars/eyes-g.png',
};
const DEFAULT_FACE_PARTS_MOUTH = {
'_none_': null,
'a': '/client-assets/world/avatars/mouth-a.png',
'b': '/client-assets/world/avatars/mouth-b.png',
'c': '/client-assets/world/avatars/mouth-c.png',
'd': '/client-assets/world/avatars/mouth-d.png',
'e': '/client-assets/world/avatars/mouth-e.png',
'f': '/client-assets/world/avatars/mouth-f.png',
};
export class PlayerContainer {
public id: string;
private profile: PlayerProfile;
@ -67,9 +88,27 @@ export class PlayerContainer {
const avatarTex = new BABYLON.Texture(this.profile.avatarUrl, this.scene, false, false);
let eyesTex: BABYLON.Texture | null = null;
if (this.profile.worldAvatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
const eyesTexPath = DEFAULT_FACE_PARTS_EYES[this.profile.worldAvatar.eyes.type];
if (eyesTexPath) {
eyesTex = new BABYLON.Texture(eyesTexPath, this.scene, false, false);
eyesTex.hasAlpha = true;
}
}
let mouthTex: BABYLON.Texture | null = null;
if (this.profile.worldAvatar.mouth.type in DEFAULT_FACE_PARTS_MOUTH) {
const mouthTexPath = DEFAULT_FACE_PARTS_MOUTH[this.profile.worldAvatar.mouth.type];
if (mouthTexPath) {
mouthTex = new BABYLON.Texture(mouthTexPath, this.scene, false, false);
mouthTex.hasAlpha = true;
}
}
for (const mesh of this.modelRoot.getChildMeshes()) {
if (mesh.name.includes('__AVATAR__')) {
const mat = new BABYLON.PBRMaterial(`${mesh.name}-mat`, this.scene);
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(0.5, 0.5, 0.5);
mat.albedoTexture = avatarTex;
mat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
@ -82,6 +121,26 @@ export class PlayerContainer {
if (mesh.name.includes('__BODY__')) {
mesh.material.albedoColor = new BABYLON.Color3(this.profile.worldAvatar.body.color[0], this.profile.worldAvatar.body.color[1], this.profile.worldAvatar.body.color[2]);
}
if (mesh.name.includes('__EYES__')) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.worldAvatar.eyes.color[0], this.profile.worldAvatar.eyes.color[1], this.profile.worldAvatar.eyes.color[2]);
mat.albedoTexture = eyesTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
}
if (mesh.name.includes('__MOUTH__')) {
if (mouthTex != null) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.worldAvatar.mouth.color[0], this.profile.worldAvatar.mouth.color[1], this.profile.worldAvatar.mouth.color[2]);
mat.albedoTexture = mouthTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
} else {
mesh.isVisible = false;
}
}
}
this.registerMeshes(this.modelRoot.getChildMeshes());
@ -94,9 +153,6 @@ export class PlayerContainer {
{ frame: 90, value: cm(2) },
{ frame: 120, value: cm(0) },
]);
//const easing = new BABYLON.CubicEase();
//easing.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
//anim.setEasingFunction(easing);
this.modelRootContainerForAnim.animations = [anim];
this.animationObserver = this.scene.onAfterAnimationsObservable.add(() => {
this.sr.updateMesh(this.modelRootContainerForAnim.getChildMeshes(), false);

View file

@ -44,7 +44,7 @@ export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに
this.scene.autoClear = false;
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.scene.clearColor = new BABYLON.Color4(0.03, 0.03, 0.03, 1);
this.scene.clearColor = new BABYLON.Color4(0.01, 0.01, 0.01, 1);
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
@ -160,8 +160,6 @@ export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに
this.camera.fov = 0.5;
this.camera.lowerRadiusLimit = cm(50);
this.camera.upperRadiusLimit = cm(1000);
this.camera.useAutoRotationBehavior = true;
this.camera.autoRotationBehavior!.idleRotationSpeed = 0.3;
//this.camera.mode = BABYLON.Camera.ORTHOGRAPHIC_CAMERA;
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.centerWorld.y, 0));
this.camera.inputs.clear();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -9,7 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="1000"
:height="600"
:scroll="false"
:withOkButton="false"
:withOkButton="true"
@ok="ok"
@close="cancel()"
@closed="emit('closed')"
>
@ -35,9 +36,63 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="prefer.s.animation ? $style.transition_options_leaveTo : ''"
>
<div v-if="showOptions" :class="$style.customize">
<MkInput :modelValue="getHex(avatar.body.color)" type="color" :throttle="300" @update:modelValue="v => { const c = getRgb(v); if (c != null) avatar.body.color = c; }">
<template #label>{{ i18n.ts.color }}</template>
</MkInput>
<div class="_gaps">
<MkInput v-model="avatarName">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkFolder>
<template #label>{{ i18n.ts._miWorld._avatars._default.body }}</template>
<MkInput :modelValue="getHex(avatar.body.color)" type="color" :throttle="300" @update:modelValue="v => { const c = getRgb(v); if (c != null) avatar.body.color = c; }">
<template #label>{{ i18n.ts.color }}</template>
</MkInput>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._miWorld._avatars._default.eyes }}</template>
<MkSelect
:items="[
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
{ label: 'd', value: 'd' },
{ label: 'e', value: 'e' },
{ label: 'f', value: 'f' },
{ label: 'g', value: 'g' },
]" :modelValue="avatar.eyes.type" @update:modelValue="v => avatar.eyes.type = v"
>
<template #label>{{ i18n.ts.type }}</template>
</MkSelect>
<MkInput :modelValue="getHex(avatar.eyes.color)" type="color" :throttle="300" @update:modelValue="v => { const c = getRgb(v); if (c != null) avatar.eyes.color = c; }">
<template #label>{{ i18n.ts.color }}</template>
</MkInput>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._miWorld._avatars._default.mouth }}</template>
<MkSelect
:items="[
{ label: i18n.ts.none, value: '_none_' },
{ label: 'a', value: 'a' },
{ label: 'b', value: 'b' },
{ label: 'c', value: 'c' },
{ label: 'd', value: 'd' },
{ label: 'e', value: 'e' },
{ label: 'f', value: 'f' },
]" :modelValue="avatar.mouth.type" @update:modelValue="v => avatar.mouth.type = v"
>
<template #label>{{ i18n.ts.type }}</template>
</MkSelect>
<MkInput :modelValue="getHex(avatar.mouth.color)" type="color" :throttle="300" @update:modelValue="v => { const c = getRgb(v); if (c != null) avatar.mouth.color = c; }">
<template #label>{{ i18n.ts.color }}</template>
</MkInput>
</MkFolder>
</div>
</div>
</Transition>
</div>
@ -49,11 +104,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick, shallowRef, computed, triggerRef, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import { OBJECT_SCHEMA_DEFS } from 'misskey-world/src/room/object-schema-defs.js';
import { getHex, getRgb } from 'misskey-world/src/utility.js';
import MkFolder from './MkFolder.vue';
import type { Ref } from 'vue';
import type { RawOptions } from 'misskey-world/src/room/object.js';
import type { RoomAttachments } from 'misskey-world/src/room/type.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
import type { AvatarPreviewEngineControllerOptions } from '@/world/avatarPreviewEngineController.js';
import { AvatarPreviewEngineController } from '@/world/avatarPreviewEngineController.js';
@ -61,23 +114,26 @@ import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import { prefer } from '@/preferences.js';
import { deepClone } from '@/utility/clone.js';
import { store } from '@/store.js';
import MkInput from '@/components/MkInput.vue';
import { withTimeout } from '@/utility/promise-timeout.js';
import { $i } from '@/i.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const props = defineProps<{
graphicsQuality: number;
avatar: WorldAvatar | null;
name: string | null;
}>();
const emit = defineEmits<{
(ev: 'ok', ctx: {
id: string;
options: RawOptions;
attachments: RoomAttachments;
avatar: WorldAvatar;
name: string;
}): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
@ -105,6 +161,8 @@ const avatar: Ref<WorldAvatar> = ref(props.avatar != null ? deepClone(props.avat
accessories: [],
});
const avatarName = ref(props.name ?? 'untitled');
const avatarPreviewEngineControllerOptions = computed<AvatarPreviewEngineControllerOptions>(() => ({
graphicsQuality: props.graphicsQuality,
fps: null,
@ -121,7 +179,7 @@ watch(avatar, () => {
onMounted(async () => {
try {
await controller.init(canvas.value!, {
name: $i.name,
name: $i.name ?? $i.username,
username: $i.username,
avatarUrl: $i.avatarUrl,
worldAvatar: avatar.value,
@ -143,24 +201,15 @@ onUnmounted(() => {
controller.destroy();
});
function updateObjectOption(k: string, v: any) {
controller.updateObjectOption(k, v, attachments);
selectedObjectOptionsState.value![k] = v;
}
//function updateAvatarOption(k: string, v: any) {
// avatar.value[k] = v;
// controller.updateAvatar(avatar.value);
//}
function ok() {
if (selectedId.value == null) return;
let recentlyUsed = store.s.recentlyUsedRoomObjects;
if (recentlyUsed.includes(selectedId.value)) recentlyUsed = recentlyUsed.filter(id => id !== selectedId.value);
recentlyUsed.unshift(selectedId.value);
if (recentlyUsed.length > 30) recentlyUsed.pop();
store.set('recentlyUsedRoomObjects', recentlyUsed);
emit('ok', {
id: selectedId.value,
options: deepClone(selectedObjectOptionsState.value!),
attachments: deepClone(attachments),
avatar: deepClone(avatar.value!),
name: avatarName.value,
});
dialog.value?.close();

View file

@ -0,0 +1,81 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div class="_gaps">
<MkPagination v-slot="{items}" :paginator="paginator">
<div class="_gaps_s">
<div v-for="avatar in items" :key="avatar.id">
<div>{{ avatar.name }}</div>
<div>{{ avatar.active }}</div>
<MkButton small rounded iconOnly @click="editWorldAvatar($event, avatar)"><i class="ti ti-pencil"></i></MkButton>
</div>
</div>
</MkPagination>
<MkButton iconOnly rounded style="margin: 0 auto;" @click="createWorldAvatar"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { markRaw } from 'vue';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import MkButton from '@/components/MkButton.vue';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { Paginator } from '@/utility/paginator.js';
import MkPagination from '@/components/MkPagination.vue';
const paginator = markRaw(new Paginator('world/avatars/list', {
limit: 10,
}));
async function createWorldAvatar(ev: PointerEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('./MkWorldAvatarEditDialog.vue').then(x => x.default), {
graphicsQuality: prefer.s['world.graphicsQuality'] ?? 0,
avatar: null,
}, {
ok: async (res) => {
await os.apiWithDialog('world/avatars/create', {
name: res.name,
def: res.avatar,
});
paginator.reload();
},
closed: () => {
dispose();
},
});
}
async function editWorldAvatar(ev: PointerEvent, item: Misskey.entities.WorldAvatarsListResponse[number]) {
const { dispose } = await os.popupAsyncWithDialog(import('./MkWorldAvatarEditDialog.vue').then(x => x.default), {
graphicsQuality: prefer.s['world.graphicsQuality'] ?? 0,
avatar: item.def,
name: item.name,
}, {
ok: async (res) => {
await os.apiWithDialog('world/avatars/update', {
avatarId: item.id,
name: res.name,
def: res.avatar,
});
paginator.reload();
},
closed: () => {
dispose();
},
});
}
</script>
<style module>
.root {
position: relative;
}
</style>

View file

@ -829,11 +829,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-user"></i></template>
<template #label><SearchLabel>{{ i18n.ts._miWorld.avatar }}</SearchLabel></template>
<div class="_gaps">
<div class="_gaps_s">
<MkButton iconOnly rounded style="margin: 0 auto;" @click="createWorldAvatar"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
<MkWorldAvatarManager/>
</MkFolder>
</SearchMarker>
</div>
@ -970,6 +966,7 @@ import MkDisableSection from '@/components/MkDisableSection.vue';
import FormLink from '@/components/form/link.vue';
import MkLink from '@/components/MkLink.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkWorldAvatarManager from '@/components/MkWorldAvatarManager.vue';
import { store } from '@/store.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -1233,20 +1230,6 @@ function testNotification(): void {
}, 300);
}
async function createWorldAvatar(ev: PointerEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('../rooms/edit-world-avatar-dialog.vue').then(x => x.default), {
graphicsQuality: worldGraphicsQuality.value ?? 0,
avatar: null,
}, {
ok: async (res) => {
console.log(res);
},
closed: () => {
dispose();
},
});
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);

View file

@ -13319,6 +13319,22 @@ export interface Locale extends ILocale {
*
*/
"avatar": string;
"_avatars": {
"_default": {
/**
*
*/
"body": string;
/**
*
*/
"eyes": string;
/**
*
*/
"mouth": string;
};
};
};
"_miRoom": {
/**

View file

@ -37246,7 +37246,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['WorldAvatarLite'][];
'application/json': components['schemas']['WorldAvatarDetailed'][];
};
};
/** @description Client error */