forked from xiangwang25/whale-town-end-v2
feat: add personal space visits and editor
This commit is contained in:
@@ -56,7 +56,8 @@ export interface AccountProfilePayload {
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountSettings = Record<string, boolean | number>;
|
||||
export type RoomVisitPolicy = 'friends' | 'public' | 'closed';
|
||||
export type AccountSettings = Record<string, boolean | number | RoomVisitPolicy>;
|
||||
|
||||
export interface UpdateAccountProfileRequest {
|
||||
skin_id?: string;
|
||||
@@ -101,6 +102,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
|
||||
allow_nearby_private: true,
|
||||
allow_nearby_friend_requests: true,
|
||||
allow_nearby_profile: true,
|
||||
room_visit_policy: 'friends',
|
||||
mute_ui_sfx: false,
|
||||
};
|
||||
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']);
|
||||
@@ -118,6 +120,7 @@ const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
|
||||
'allow_nearby_profile',
|
||||
'mute_ui_sfx',
|
||||
]);
|
||||
const ROOM_VISIT_POLICIES = new Set<RoomVisitPolicy>(['friends', 'public', 'closed']);
|
||||
|
||||
export interface AccountSkinAsset {
|
||||
id: string;
|
||||
@@ -669,6 +672,8 @@ export class AccountProfileService {
|
||||
}
|
||||
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
|
||||
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
|
||||
} else if (key === 'room_visit_policy' && typeof value === 'string' && ROOM_VISIT_POLICIES.has(value as RoomVisitPolicy)) {
|
||||
sanitized[key] = value as RoomVisitPolicy;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ROOM_DECOR_DEFINITIONS } from '../room_decor/room_decor_catalog';
|
||||
|
||||
export type MallItemType = 'skin' | 'room_decor';
|
||||
|
||||
export interface MallCatalogItem {
|
||||
@@ -23,6 +25,19 @@ export const MALL_CATEGORIES = [
|
||||
{ id: 'limited', label: '限时', icon: 'limited' },
|
||||
];
|
||||
|
||||
const ROOM_DECOR_MALL_ITEMS: MallCatalogItem[] = ROOM_DECOR_DEFINITIONS.map((definition) => ({
|
||||
itemId: definition.item_id,
|
||||
itemType: 'room_decor',
|
||||
decorId: definition.decor_id,
|
||||
icon: definition.icon,
|
||||
name: definition.name,
|
||||
category: definition.shop.category,
|
||||
description: definition.shop.description,
|
||||
price: definition.shop.price,
|
||||
tags: definition.shop.tags,
|
||||
sortOrder: definition.shop.sort_order,
|
||||
}));
|
||||
|
||||
export const MALL_ITEMS: MallCatalogItem[] = [
|
||||
{
|
||||
itemId: 'skin_classic_whale',
|
||||
@@ -81,126 +96,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_floor_rug',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_floor_rug',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||
name: '鲸浪地毯',
|
||||
category: 'space',
|
||||
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
||||
price: 260,
|
||||
tags: ['房间家具', '可拖拽', '地面'],
|
||||
sortOrder: 110,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_memory_board',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_memory_board',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||
name: '鲸语记忆板',
|
||||
category: 'space',
|
||||
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
||||
price: 220,
|
||||
tags: ['房间家具', '可拖拽', '挂件'],
|
||||
sortOrder: 120,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_lamp',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_lamp',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||
name: '鲸尾暖灯',
|
||||
category: 'space',
|
||||
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
||||
price: 360,
|
||||
tags: ['房间家具', '可拖拽', '灯具'],
|
||||
sortOrder: 130,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_boat_cabin_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'boat_cabin_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||
name: '船舱小床',
|
||||
category: 'space',
|
||||
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
||||
price: 520,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 140,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_low_wave_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'low_wave_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||
name: '海浪低床',
|
||||
category: 'space',
|
||||
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
||||
price: 500,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 150,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_headboard_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_headboard_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||
name: '鲸尾床头床',
|
||||
category: 'space',
|
||||
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
||||
price: 580,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 180,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_dev_whale_bookshelf',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'dev_whale_bookshelf',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
name: '程序员鲸书架',
|
||||
category: 'space',
|
||||
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
||||
price: 620,
|
||||
tags: ['房间家具', '可拖拽', '书架'],
|
||||
sortOrder: 190,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_bug_feature_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_bug_feature_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||
name: 'BUG特性徽章',
|
||||
category: 'space',
|
||||
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 200,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_buddhist_learning_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_buddhist_learning_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||
name: '佛系学习徽章',
|
||||
category: 'space',
|
||||
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
||||
price: 140,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 210,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_ok_working_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_ok_working_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||
name: '已经在做徽章',
|
||||
category: 'space',
|
||||
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 220,
|
||||
},
|
||||
...ROOM_DECOR_MALL_ITEMS,
|
||||
];
|
||||
|
||||
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);
|
||||
|
||||
@@ -48,5 +48,5 @@ export interface PlayerSnapshotPayload {
|
||||
owned_skin_ids: string[];
|
||||
owned_skins: unknown[];
|
||||
};
|
||||
settings: Record<string, boolean | number>;
|
||||
settings: Record<string, boolean | number | string>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
export class SaveRoomDecorPlacementDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: '摆件ID必须是字符串' })
|
||||
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
|
||||
decor_id!: string;
|
||||
decor_id?: string;
|
||||
|
||||
@IsBoolean({ message: '摆放状态必须是布尔值' })
|
||||
placed!: boolean;
|
||||
@@ -27,6 +28,13 @@ export class SaveRoomDecorPlacementDto {
|
||||
@Max(4, { message: '缩放不能太大' })
|
||||
scale?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '旋转角度必须是数字' })
|
||||
@IsInt({ message: '旋转角度必须是整数' })
|
||||
@Min(0, { message: '旋转角度超出范围' })
|
||||
@Max(270, { message: '旋转角度超出范围' })
|
||||
rotation_degrees?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '层级必须是数字' })
|
||||
@Min(-1000, { message: '层级超出范围' })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpStatus, Param, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Post, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
@@ -9,7 +9,7 @@ import { RoomDecorService } from './room_decor.service';
|
||||
|
||||
@ApiTags('room-decor')
|
||||
@ApiBearerAuth()
|
||||
@Controller('rooms/me/decor-placements')
|
||||
@Controller('rooms')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RoomDecorController {
|
||||
constructor(private readonly roomDecorService: RoomDecorService) {}
|
||||
@@ -22,7 +22,7 @@ export class RoomDecorController {
|
||||
status: 200,
|
||||
description: '房间家具背包获取成功',
|
||||
})
|
||||
@Get()
|
||||
@Get('me/decor-placements')
|
||||
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.roomDecorService.getInventory(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({
|
||||
@@ -32,6 +32,40 @@ export class RoomDecorController {
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '参观其他玩家的房间布局',
|
||||
description: '按房主访问策略返回只读布局;拉黑关系始终拒绝访问。',
|
||||
})
|
||||
@SwaggerApiResponse({ status: 200, description: '只读房间布局获取成功' })
|
||||
@Get(':ownerUserId/decor-placements')
|
||||
async getRoomView(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('ownerUserId') ownerUserId: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.roomDecorService.getRoomView(BigInt(user.sub), this.parseUserId(ownerUserId));
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '房间布局获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '收回当前账号所有已摆放家具',
|
||||
description: '仅清空布局,不删除已拥有家具资产。',
|
||||
})
|
||||
@SwaggerApiResponse({ status: 200, description: '房间布局已重置' })
|
||||
@Post('me/decor-placements/reset')
|
||||
async resetPlacements(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '房间布局已重置,家具已收回背包',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '保存房间家具摆放',
|
||||
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
|
||||
@@ -40,7 +74,7 @@ export class RoomDecorController {
|
||||
status: 200,
|
||||
description: '家具摆放保存成功',
|
||||
})
|
||||
@Put(':decorId')
|
||||
@Put('me/decor-placements/:decorId')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async savePlacement(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@@ -58,4 +92,11 @@ export class RoomDecorController {
|
||||
message: '家具摆放保存成功',
|
||||
});
|
||||
}
|
||||
|
||||
private parseUserId(value: string): bigint {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new BadRequestException('房主用户ID格式不正确');
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { InventoryService } from '../player/inventory.service';
|
||||
import { SocialService } from '../social/social.service';
|
||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||
import {
|
||||
ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ROOM_DECOR_LEGACY_BED_MAX_SCALE,
|
||||
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
|
||||
ROOM_DECOR_ROOM_SCALE,
|
||||
RoomDecorDefinition,
|
||||
findRoomDecorDefinition,
|
||||
} from './room_decor_catalog';
|
||||
|
||||
@@ -17,6 +19,7 @@ interface UserRoomDecorRow {
|
||||
position_x: number | null;
|
||||
position_y: number | null;
|
||||
scale: number;
|
||||
rotation_degrees?: number;
|
||||
z_index: number;
|
||||
}
|
||||
|
||||
@@ -29,6 +32,7 @@ interface RoomDecorPayloadPlacement {
|
||||
position_x: number | null;
|
||||
position_y: number | null;
|
||||
scale: number;
|
||||
rotation_degrees: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -36,6 +40,7 @@ export class RoomDecorService {
|
||||
constructor(
|
||||
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly socialService: SocialService,
|
||||
) {}
|
||||
|
||||
async getInventory(userId: bigint) {
|
||||
@@ -53,6 +58,7 @@ export class RoomDecorService {
|
||||
position_x: definition?.default_position.x ?? null,
|
||||
position_y: definition?.default_position.y ?? null,
|
||||
scale: definition?.default_scale ?? 1,
|
||||
rotation_degrees: definition?.default_rotation_degrees ?? 0,
|
||||
z_index: definition?.default_z_index ?? 0,
|
||||
};
|
||||
});
|
||||
@@ -65,21 +71,63 @@ export class RoomDecorService {
|
||||
}
|
||||
|
||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
|
||||
const definition = findRoomDecorDefinition(placement.decor_id);
|
||||
const decorId = placement.decor_id?.trim() || '';
|
||||
const definition = findRoomDecorDefinition(decorId);
|
||||
if (!definition) {
|
||||
throw new BadRequestException('摆件不存在或暂未开放');
|
||||
}
|
||||
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', placement.decor_id))) {
|
||||
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', decorId))) {
|
||||
throw new BadRequestException('尚未拥有该房间摆件');
|
||||
}
|
||||
const rotationDegrees = placement.rotation_degrees ?? definition.default_rotation_degrees;
|
||||
if (!definition.rotation_steps.includes(rotationDegrees)) {
|
||||
throw new BadRequestException('该摆件不支持此旋转角度');
|
||||
}
|
||||
const row = await this.roomDecorPlacementsService.savePlacement(userId, {
|
||||
...placement,
|
||||
decor_id: decorId,
|
||||
position_x: this.snapToGrid(placement.position_x, definition.grid_size),
|
||||
position_y: this.snapToGrid(placement.position_y, definition.grid_size),
|
||||
scale: placement.scale ?? definition.default_scale,
|
||||
rotation_degrees: rotationDegrees,
|
||||
z_index: placement.z_index ?? definition.default_z_index,
|
||||
});
|
||||
return this.toPayload(row);
|
||||
}
|
||||
|
||||
async getRoomView(viewerId: bigint, ownerId: bigint) {
|
||||
const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId);
|
||||
const [inventory, placements] = await Promise.all([
|
||||
this.inventoryService.listInventory(ownerId, 'room_decor'),
|
||||
this.roomDecorPlacementsService.listPlacements(ownerId),
|
||||
]);
|
||||
const ownedDecorIds = new Set(inventory.room_decor_ids);
|
||||
return {
|
||||
owner,
|
||||
read_only: true,
|
||||
items: placements
|
||||
.filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id)))
|
||||
.map((row) => this.toPayload(row)),
|
||||
definitions: ROOM_DECOR_DEFINITIONS,
|
||||
};
|
||||
}
|
||||
|
||||
async resetPlacements(userId: bigint) {
|
||||
const inventory = await this.inventoryService.listInventory(userId, 'room_decor');
|
||||
await Promise.all(inventory.room_decor_ids.map(async (decorId) => {
|
||||
const definition = findRoomDecorDefinition(decorId);
|
||||
if (!definition) return;
|
||||
await this.roomDecorPlacementsService.savePlacement(userId, {
|
||||
decor_id: decorId,
|
||||
placed: false,
|
||||
scale: definition.default_scale,
|
||||
rotation_degrees: definition.default_rotation_degrees,
|
||||
z_index: definition.default_z_index,
|
||||
});
|
||||
}));
|
||||
return this.getInventory(userId);
|
||||
}
|
||||
|
||||
private toPayload(row: UserRoomDecorRow) {
|
||||
const definition = findRoomDecorDefinition(row.decor_id);
|
||||
const placement = this.normalizedPlacement(row, definition);
|
||||
@@ -93,26 +141,34 @@ export class RoomDecorService {
|
||||
position_x: placement.position_x,
|
||||
position_y: placement.position_y,
|
||||
scale: placement.scale,
|
||||
rotation_degrees: placement.rotation_degrees,
|
||||
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
||||
category: definition?.category ?? 'floor',
|
||||
placement_surface: definition?.placement_surface ?? 'floor',
|
||||
grid_size: definition?.grid_size ?? 16,
|
||||
rotation_steps: definition?.rotation_steps ?? [0],
|
||||
stackable: definition?.stackable ?? false,
|
||||
default_position: definition?.default_position ?? { x: 0, y: 0 },
|
||||
default_scale: definition?.default_scale ?? 1,
|
||||
default_rotation_degrees: definition?.default_rotation_degrees ?? 0,
|
||||
default_z_index: definition?.default_z_index ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizedPlacement(
|
||||
row: UserRoomDecorRow,
|
||||
definition?: { default_scale: number; default_position: { x: number; y: number } },
|
||||
definition?: RoomDecorDefinition,
|
||||
): RoomDecorPayloadPlacement {
|
||||
const usesLegacyPlacement = this.usesLegacyPlacement(row);
|
||||
return {
|
||||
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
|
||||
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
|
||||
scale: this.normalizedScale(row, definition),
|
||||
rotation_degrees: this.normalizedRotation(row, definition),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizedScale(row: UserRoomDecorRow, definition?: { default_scale: number }) {
|
||||
private normalizedScale(row: UserRoomDecorRow, definition?: Pick<RoomDecorDefinition, 'default_scale'>) {
|
||||
const scale = row.scale ?? definition?.default_scale ?? 1;
|
||||
if (!row.placed && definition) {
|
||||
return definition.default_scale;
|
||||
@@ -146,6 +202,16 @@ export class RoomDecorService {
|
||||
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
|
||||
}
|
||||
|
||||
private normalizedRotation(row: UserRoomDecorRow, definition?: RoomDecorDefinition): number {
|
||||
const rotation = row.rotation_degrees ?? definition?.default_rotation_degrees ?? 0;
|
||||
return definition?.rotation_steps.includes(rotation) ? rotation : definition?.default_rotation_degrees ?? 0;
|
||||
}
|
||||
|
||||
private snapToGrid(value: number | undefined, gridSize: number): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
private usesLegacyPlacement(row: UserRoomDecorRow) {
|
||||
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
|
||||
if (!legacy) {
|
||||
|
||||
@@ -2,14 +2,27 @@ export interface RoomDecorDefinition {
|
||||
decor_id: string;
|
||||
name: string;
|
||||
item_id: string;
|
||||
category: 'floor' | 'rug' | 'wall';
|
||||
placement_surface: 'floor' | 'wall';
|
||||
grid_size: number;
|
||||
rotation_steps: number[];
|
||||
stackable: boolean;
|
||||
icon: string;
|
||||
texture?: string;
|
||||
default_scale: number;
|
||||
default_rotation_degrees: number;
|
||||
default_position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
default_z_index: number;
|
||||
shop: {
|
||||
category: 'space';
|
||||
description: string;
|
||||
price: number;
|
||||
tags: string[];
|
||||
sort_order: number;
|
||||
};
|
||||
collision_size?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -84,30 +97,51 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'whale_floor_rug',
|
||||
item_id: 'decor_whale_floor_rug',
|
||||
name: '鲸浪地毯',
|
||||
category: 'rug',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
|
||||
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 0, y: 161 },
|
||||
default_z_index: -8,
|
||||
shop: { category: 'space', description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', price: 260, tags: ['房间家具', '可拖拽', '地面'], sort_order: 110 },
|
||||
},
|
||||
{
|
||||
decor_id: 'whale_memory_board',
|
||||
item_id: 'decor_whale_memory_board',
|
||||
name: '鲸语记忆板',
|
||||
category: 'wall',
|
||||
placement_surface: 'wall',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||
default_scale: 0.11,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 182, y: -207 },
|
||||
default_z_index: -14,
|
||||
shop: { category: 'space', description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', price: 220, tags: ['房间家具', '可拖拽', '挂件'], sort_order: 120 },
|
||||
},
|
||||
{
|
||||
decor_id: 'whale_tail_lamp',
|
||||
item_id: 'decor_whale_tail_lamp',
|
||||
name: '鲸尾暖灯',
|
||||
category: 'floor',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0, 90, 180, 270],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
|
||||
default_scale: 1,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 231, y: -175 },
|
||||
default_z_index: -10,
|
||||
shop: { category: 'space', description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', price: 360, tags: ['房间家具', '可拖拽', '灯具'], sort_order: 130 },
|
||||
collision_size: { x: 50, y: 32 },
|
||||
collision_offset: { x: 0, y: 56 },
|
||||
},
|
||||
@@ -115,11 +149,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'boat_cabin_bed',
|
||||
item_id: 'decor_boat_cabin_bed',
|
||||
name: '船舱小床',
|
||||
category: 'floor',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0, 90, 180, 270],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: -161, y: 25 },
|
||||
default_z_index: -9,
|
||||
shop: { category: 'space', description: '白木船舱造型的小床,适合放在个人房间地面区域。', price: 520, tags: ['房间家具', '可拖拽', '床'], sort_order: 140 },
|
||||
collision_size: { x: 220, y: 112 },
|
||||
collision_offset: { x: 0, y: 52 },
|
||||
},
|
||||
@@ -127,11 +168,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'low_wave_bed',
|
||||
item_id: 'decor_low_wave_bed',
|
||||
name: '海浪低床',
|
||||
category: 'floor',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0, 90, 180, 270],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: -98, y: 39 },
|
||||
default_z_index: -9,
|
||||
shop: { category: 'space', description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', price: 500, tags: ['房间家具', '可拖拽', '床'], sort_order: 150 },
|
||||
collision_size: { x: 220, y: 112 },
|
||||
collision_offset: { x: 0, y: 56 },
|
||||
},
|
||||
@@ -139,11 +187,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'whale_tail_headboard_bed',
|
||||
item_id: 'decor_whale_tail_headboard_bed',
|
||||
name: '鲸尾床头床',
|
||||
category: 'floor',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0, 90, 180, 270],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 0, y: 32 },
|
||||
default_z_index: -9,
|
||||
shop: { category: 'space', description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', price: 580, tags: ['房间家具', '可拖拽', '床'], sort_order: 180 },
|
||||
collision_size: { x: 214, y: 112 },
|
||||
collision_offset: { x: 0, y: 62 },
|
||||
},
|
||||
@@ -151,11 +206,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'dev_whale_bookshelf',
|
||||
item_id: 'decor_dev_whale_bookshelf',
|
||||
name: '程序员鲸书架',
|
||||
category: 'floor',
|
||||
placement_surface: 'floor',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0, 90, 180, 270],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: -210, y: -39 },
|
||||
default_z_index: -10,
|
||||
shop: { category: 'space', description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。', price: 620, tags: ['房间家具', '可拖拽', '书架'], sort_order: 190 },
|
||||
collision_size: { x: 626.667, y: 226.667 },
|
||||
collision_offset: { x: 0, y: 580 },
|
||||
},
|
||||
@@ -163,31 +225,52 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
decor_id: 'datawhale_bug_feature_badge',
|
||||
item_id: 'decor_datawhale_bug_feature_badge',
|
||||
name: 'BUG特性徽章',
|
||||
category: 'wall',
|
||||
placement_surface: 'wall',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: -210, y: -203 },
|
||||
default_z_index: -14,
|
||||
shop: { category: 'space', description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 200 },
|
||||
},
|
||||
{
|
||||
decor_id: 'datawhale_buddhist_learning_badge',
|
||||
item_id: 'decor_datawhale_buddhist_learning_badge',
|
||||
name: '佛系学习徽章',
|
||||
category: 'wall',
|
||||
placement_surface: 'wall',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 0, y: -203 },
|
||||
default_z_index: -14,
|
||||
shop: { category: 'space', description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', price: 140, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 210 },
|
||||
},
|
||||
{
|
||||
decor_id: 'datawhale_ok_working_badge',
|
||||
item_id: 'decor_datawhale_ok_working_badge',
|
||||
name: '已经在做徽章',
|
||||
category: 'wall',
|
||||
placement_surface: 'wall',
|
||||
grid_size: 16,
|
||||
rotation_steps: [0],
|
||||
stackable: false,
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_rotation_degrees: 0,
|
||||
default_position: { x: 210, y: -203 },
|
||||
default_z_index: -14,
|
||||
shop: { category: 'space', description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 220 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const DEFAULT_PRIVACY = {
|
||||
allow_nearby_private: true,
|
||||
allow_nearby_friend_requests: true,
|
||||
};
|
||||
const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']);
|
||||
const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = {
|
||||
whale_port: { x: 1280, y: 960 },
|
||||
work_zone: { x: 1280, y: 960 },
|
||||
@@ -163,6 +164,26 @@ export class SocialService {
|
||||
return this.buildProfile(viewerId, targetId, self);
|
||||
}
|
||||
|
||||
async canVisitRoom(viewerId: bigint, ownerId: bigint): Promise<boolean> {
|
||||
if (viewerId === ownerId) return true;
|
||||
const profile = await this.ensureProfile(ownerId);
|
||||
return await this.canVisitRoomWithProfile(viewerId, ownerId, profile);
|
||||
}
|
||||
|
||||
async getRoomOwnerProfile(viewerId: bigint, ownerId: bigint) {
|
||||
if (!(await this.canVisitRoom(viewerId, ownerId))) {
|
||||
throw new ForbiddenException('该玩家的个人空间暂不可访问');
|
||||
}
|
||||
const profile = await this.buildProfile(viewerId, ownerId, viewerId === ownerId);
|
||||
return {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
skinId: profile.skinId,
|
||||
};
|
||||
}
|
||||
|
||||
async getFriends(userId: bigint) {
|
||||
const friendships = await this.store.listFriendships(userId);
|
||||
const result = [];
|
||||
@@ -416,6 +437,8 @@ export class SocialService {
|
||||
const tags = this.profileTags(profile);
|
||||
const session = socketId ? await this.sessions.getSession(socketId) : null;
|
||||
const testPresence = getTestLabPresence(targetId);
|
||||
const isFriend = viewerId === targetId ? false : await this.areFriends(viewerId, targetId);
|
||||
const blockedEitherWay = viewerId === targetId ? false : await this.isBlockedEitherWay(viewerId, targetId);
|
||||
return {
|
||||
id: targetId.toString(),
|
||||
username: user.username,
|
||||
@@ -427,8 +450,9 @@ export class SocialService {
|
||||
bio: String(profile.bio || '').slice(0, 160),
|
||||
interests: this.validInterests(tags.interests),
|
||||
privacy: includePrivate ? this.privacy(profile) : undefined,
|
||||
isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId),
|
||||
blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId),
|
||||
isFriend: includePrivate || viewerId === targetId ? false : isFriend,
|
||||
blocked: includePrivate ? false : blockedEitherWay,
|
||||
room_visitable: viewerId === targetId || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'public') || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'friends' && isFriend),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -451,6 +475,23 @@ export class SocialService {
|
||||
return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) };
|
||||
}
|
||||
|
||||
private roomVisitPolicy(profile: ProfileRecord): 'friends' | 'public' | 'closed' {
|
||||
const value = this.profileTags(profile)[SOCIAL_SETTINGS_KEY];
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const policy = String((value as Record<string, unknown>).room_visit_policy || 'friends');
|
||||
if (ROOM_VISIT_POLICIES.has(policy)) return policy as 'friends' | 'public' | 'closed';
|
||||
}
|
||||
return 'friends';
|
||||
}
|
||||
|
||||
private async canVisitRoomWithProfile(viewerId: bigint, ownerId: bigint, profile: ProfileRecord): Promise<boolean> {
|
||||
if (viewerId === ownerId) return true;
|
||||
if (await this.isBlockedEitherWay(viewerId, ownerId)) return false;
|
||||
const policy = this.roomVisitPolicy(profile);
|
||||
if (policy === 'public') return true;
|
||||
return policy === 'friends' && await this.areFriends(viewerId, ownerId);
|
||||
}
|
||||
|
||||
private validInterests(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : [];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` (
|
||||
`position_x` FLOAT NULL COMMENT '房间内X坐标',
|
||||
`position_y` FLOAT NULL COMMENT '房间内Y坐标',
|
||||
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
|
||||
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
|
||||
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `room_decor_placements`
|
||||
ADD COLUMN IF NOT EXISTS `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度' AFTER `scale`;
|
||||
@@ -25,6 +25,9 @@ export class RoomDecorPlacements {
|
||||
@Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' })
|
||||
scale: number;
|
||||
|
||||
@Column({ type: 'float', nullable: false, default: 0, comment: '摆件旋转角度' })
|
||||
rotation_degrees: number;
|
||||
|
||||
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
|
||||
z_index: number;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export class RoomDecorPlacementsService {
|
||||
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
||||
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||
row.updated_at = new Date();
|
||||
return await this.placementsRepository.save(row);
|
||||
|
||||
@@ -34,6 +34,7 @@ export class RoomDecorPlacementsMemoryService {
|
||||
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null;
|
||||
row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
|
||||
row.scale = Number(placement.scale ?? row.scale ?? 1);
|
||||
row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0);
|
||||
row.z_index = Number(placement.z_index ?? row.z_index ?? 0);
|
||||
row.updated_at = new Date();
|
||||
return row;
|
||||
|
||||
@@ -1108,6 +1108,11 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
}
|
||||
|
||||
private legacyFriend(profile: any) {
|
||||
return { userId: String(profile.id), username: String(profile.nickname || profile.username || '玩家'), online: Boolean(profile.online) };
|
||||
return {
|
||||
userId: String(profile.id),
|
||||
username: String(profile.nickname || profile.username || '玩家'),
|
||||
online: Boolean(profile.online),
|
||||
room_visitable: Boolean(profile.room_visitable),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user