第3章:バックエンド実装
はじめに
ft_transcendenceのバックエンドは、REST API、WebSocket、データベースアクセスを提供します。本章では、NestJSを使用した実装詳細を学びます。
---
1. NestJS基礎
1.1 プロジェクト構造
backend/
├── src/
│ ├── main.ts # エントリーポイント
│ ├── app.module.ts # ルートモジュール
│ ├── auth/ # 認証モジュール
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── strategies/
│ │ │ ├── jwt.strategy.ts
│ │ │ └── 42.strategy.ts
│ │ ├── guards/
│ │ │ └── jwt-auth.guard.ts
│ │ └── dto/
│ ├── users/ # ユーザーモジュール
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── entities/
│ │ │ └── user.entity.ts
│ │ └── dto/
│ ├── chat/ # チャットモジュール
│ │ ├── chat.module.ts
│ │ ├── chat.controller.ts
│ │ ├── chat.service.ts
│ │ ├── chat.gateway.ts
│ │ └── entities/
│ ├── game/ # ゲームモジュール
│ │ ├── game.module.ts
│ │ ├── game.controller.ts
│ │ ├── game.service.ts
│ │ ├── game.gateway.ts
│ │ └── entities/
│ └── common/ # 共通モジュール
│ ├── filters/
│ ├── interceptors/
│ └── decorators/
├── test/
├── package.json
└── tsconfig.json
1.2 メインファイル
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// CORS設定
app.enableCors({
origin: process.env.FRONTEND_URL,
credentials: true,
});
// Cookieパーサー
app.use(cookieParser());
// バリデーションパイプ
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTOにないプロパティを削除
forbidNonWhitelisted: true, // 余分なプロパティでエラー
transform: true, // 型変換を有効化
}));
// グローバルプレフィックス
app.setGlobalPrefix('api');
await app.listen(3000);
console.log('Backend listening on port 3000');
}
bootstrap();
1.3 ルートモジュール
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { ChatModule } from './chat/chat.module';
import { GameModule } from './game/game.module';
@Module({
imports: [
// 環境変数
ConfigModule.forRoot({
isGlobal: true,
}),
// データベース
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production',
}),
// 機能モジュール
AuthModule,
UsersModule,
ChatModule,
GameModule,
],
})
export class AppModule {}
---
2. 認証実装
2.1 42 OAuth Strategy
// src/auth/strategies/42.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-42';
import { AuthService } from '../auth.service';
@Injectable()
export class FortyTwoStrategy extends PassportStrategy(Strategy, '42') {
constructor(private authService: AuthService) {
super({
clientID: process.env.INTRA_CLIENT_ID,
clientSecret: process.env.INTRA_CLIENT_SECRET,
callbackURL: process.env.INTRA_CALLBACK_URL,
scope: ['public'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
) {
const user = await this.authService.validateOAuthUser({
intraId: profile.id,
username: profile.username,
email: profile.emails[0].value,
avatarUrl: profile._json.image.link,
});
done(null, user);
}
}
2.2 認証コントローラー
// src/auth/auth.controller.ts
import {
Controller,
Get,
Post,
Body,
UseGuards,
Req,
Res,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
// 42 OAuth開始
@Get('login/42')
@UseGuards(AuthGuard('42'))
login42() {
// PassportがリダイレクトするのでこのメソッドBodyは実行されない
}
// OAuthコールバック
@Get('callback')
@UseGuards(AuthGuard('42'))
async callback(@Req() req, @Res() res: Response) {
const { token, user } = await this.authService.login(req.user);
// JWT を HTTP-only cookie に設定
res.cookie('jwt', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 1日
});
// 2FAが有効な場合
if (user.twoFactorEnabled) {
return res.redirect(`${process.env.FRONTEND_URL}/2fa`);
}
res.redirect(`${process.env.FRONTEND_URL}/`);
}
// 現在のユーザー情報
@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@CurrentUser() user) {
return user;
}
// ログアウト
@Post('logout')
@UseGuards(JwtAuthGuard)
logout(@Res() res: Response) {
res.clearCookie('jwt');
return res.json({ message: 'Logged out' });
}
// 2FA有効化
@Post('2fa/enable')
@UseGuards(JwtAuthGuard)
async enable2FA(@CurrentUser() user) {
return this.authService.generate2FASecret(user);
}
// 2FA検証
@Post('2fa/verify')
@UseGuards(JwtAuthGuard)
async verify2FA(
@CurrentUser() user,
@Body('code') code: string,
) {
return this.authService.verify2FA(user, code);
}
}
2.3 認証サービス
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async validateOAuthUser(profile: any) {
let user = await this.usersService.findByIntraId(profile.intraId);
if (!user) {
user = await this.usersService.create({
intraId: profile.intraId,
username: profile.username,
email: profile.email,
avatarUrl: profile.avatarUrl,
});
}
return user;
}
async login(user: any) {
const payload = {
sub: user.id,
username: user.username,
twoFactorAuthenticated: !user.twoFactorEnabled,
};
return {
token: this.jwtService.sign(payload),
user,
};
}
async generate2FASecret(user: any) {
const secret = authenticator.generateSecret();
const otpAuthUrl = authenticator.keyuri(
user.email,
'ft_transcendence',
secret,
);
// 一時的に秘密を保存
await this.usersService.update(user.id, {
twoFactorSecret: secret,
});
const qrCode = await QRCode.toDataURL(otpAuthUrl);
return { qrCode };
}
async verify2FA(user: any, code: string) {
const isValid = authenticator.verify({
token: code,
secret: user.twoFactorSecret,
});
if (!isValid) {
throw new UnauthorizedException('Invalid 2FA code');
}
await this.usersService.update(user.id, {
twoFactorEnabled: true,
});
// 完全認証済みトークンを発行
const payload = {
sub: user.id,
username: user.username,
twoFactorAuthenticated: true,
};
return {
token: this.jwtService.sign(payload),
};
}
}
2.4 JWT Guard
// src/auth/guards/jwt-auth.guard.ts
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Authentication required');
}
// 2FAが有効だが未認証の場合
if (user.twoFactorEnabled && !user.twoFactorAuthenticated) {
throw new UnauthorizedException('2FA verification required');
}
return user;
}
}
---
3. ユーザー管理
3.1 ユーザーエンティティ
// src/users/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToMany,
JoinTable,
OneToMany,
} from 'typeorm';
import { Exclude } from 'class-transformer';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true, nullable: true })
intraId: number;
@Column({ unique: true, length: 50 })
username: string;
@Column({ unique: true })
email: string;
@Column({ nullable: true })
avatarUrl: string;
@Column({ default: false })
twoFactorEnabled: boolean;
@Exclude()
@Column({ nullable: true })
twoFactorSecret: string;
@Column({ default: 'offline' })
status: 'online' | 'offline' | 'in_game';
@Column({ default: 0 })
wins: number;
@Column({ default: 0 })
losses: number;
@Column({ default: 1 })
ladderLevel: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// フレンド関係(多対多)
@ManyToMany(() => User, (user) => user.friendsOf)
@JoinTable({
name: 'friendships',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'friend_id', referencedColumnName: 'id' },
})
friends: User[];
@ManyToMany(() => User, (user) => user.friends)
friendsOf: User[];
// ブロックリスト
@ManyToMany(() => User)
@JoinTable({
name: 'blocked_users',
joinColumn: { name: 'blocker_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'blocked_id', referencedColumnName: 'id' },
})
blockedUsers: User[];
}
3.2 ユーザーサービス
// src/users/users.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findById(id: number): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async findByIntraId(intraId: number): Promise<User | null> {
return this.usersRepository.findOne({ where: { intraId } });
}
async findByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
async create(createUserDto: CreateUserDto): Promise<User> {
// ユーザー名重複チェック
const existing = await this.findByUsername(createUserDto.username);
if (existing) {
throw new ConflictException('Username already taken');
}
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findById(id);
// ユーザー名変更時の重複チェック
if (updateUserDto.username && updateUserDto.username !== user.username) {
const existing = await this.findByUsername(updateUserDto.username);
if (existing) {
throw new ConflictException('Username already taken');
}
}
Object.assign(user, updateUserDto);
return this.usersRepository.save(user);
}
async updateStatus(id: number, status: 'online' | 'offline' | 'in_game'): Promise<void> {
await this.usersRepository.update(id, { status });
}
// フレンド関連
async getFriends(userId: number): Promise<User[]> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['friends'],
});
return user?.friends || [];
}
async addFriend(userId: number, friendId: number): Promise<void> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['friends'],
});
const friend = await this.findById(friendId);
if (!user.friends.some(f => f.id === friendId)) {
user.friends.push(friend);
await this.usersRepository.save(user);
}
}
async removeFriend(userId: number, friendId: number): Promise<void> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['friends'],
});
user.friends = user.friends.filter(f => f.id !== friendId);
await this.usersRepository.save(user);
}
// ブロック関連
async getBlockedUsers(userId: number): Promise<User[]> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['blockedUsers'],
});
return user?.blockedUsers || [];
}
async blockUser(userId: number, blockedId: number): Promise<void> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['blockedUsers'],
});
const blocked = await this.findById(blockedId);
if (!user.blockedUsers.some(b => b.id === blockedId)) {
user.blockedUsers.push(blocked);
await this.usersRepository.save(user);
}
}
async isBlocked(userId: number, targetId: number): Promise<boolean> {
const user = await this.usersRepository.findOne({
where: { id: userId },
relations: ['blockedUsers'],
});
return user?.blockedUsers.some(b => b.id === targetId) || false;
}
// 統計
async getMatchHistory(userId: number) {
return this.usersRepository.manager.query(`
SELECT gh.*,
p1.username as player1_username,
p2.username as player2_username
FROM game_history gh
JOIN users p1 ON gh.player1_id = p1.id
JOIN users p2 ON gh.player2_id = p2.id
WHERE gh.player1_id = $1 OR gh.player2_id = $1
ORDER BY gh.played_at DESC
LIMIT 50
`, [userId]);
}
async updateStats(userId: number, won: boolean): Promise<void> {
if (won) {
await this.usersRepository.increment({ id: userId }, 'wins', 1);
} else {
await this.usersRepository.increment({ id: userId }, 'losses', 1);
}
// ラダーレベル更新
const user = await this.findById(userId);
const newLevel = Math.floor((user.wins - user.losses) / 5) + 1;
if (newLevel > 0 && newLevel !== user.ladderLevel) {
await this.usersRepository.update(userId, { ladderLevel: newLevel });
}
}
}
3.3 ユーザーコントローラー
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
ParseIntPipe,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get('me')
getMe(@CurrentUser() user) {
return user;
}
@Patch('me')
updateMe(@CurrentUser() user, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(user.id, updateUserDto);
}
@Post('me/avatar')
@UseInterceptors(FileInterceptor('avatar', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/^image\/(jpeg|png|gif)$/)) {
return cb(new Error('Invalid file type'), false);
}
cb(null, true);
},
}))
async uploadAvatar(
@CurrentUser() user,
@UploadedFile() file: Express.Multer.File,
) {
// ファイル保存処理(実際はクラウドストレージを使用)
const avatarUrl = `/uploads/${file.filename}`;
return this.usersService.update(user.id, { avatarUrl });
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findById(id);
}
@Get(':id/match-history')
getMatchHistory(@Param('id', ParseIntPipe) id: number) {
return this.usersService.getMatchHistory(id);
}
// フレンド
@Get('friends')
getFriends(@CurrentUser() user) {
return this.usersService.getFriends(user.id);
}
@Post('friends/:id')
addFriend(
@CurrentUser() user,
@Param('id', ParseIntPipe) friendId: number,
) {
return this.usersService.addFriend(user.id, friendId);
}
@Delete('friends/:id')
removeFriend(
@CurrentUser() user,
@Param('id', ParseIntPipe) friendId: number,
) {
return this.usersService.removeFriend(user.id, friendId);
}
// ブロック
@Get('blocks')
getBlockedUsers(@CurrentUser() user) {
return this.usersService.getBlockedUsers(user.id);
}
@Post('blocks/:id')
blockUser(
@CurrentUser() user,
@Param('id', ParseIntPipe) blockedId: number,
) {
return this.usersService.blockUser(user.id, blockedId);
}
}
---
4. チャット実装
4.1 チャットエンティティ
// src/chat/entities/channel.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
OneToMany,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { ChannelMember } from './channel-member.entity';
import { Message } from './message.entity';
@Entity('channels')
export class Channel {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ default: 'public' })
type: 'public' | 'private' | 'protected' | 'direct';
@Column({ nullable: true })
password: string;
@ManyToOne(() => User, { nullable: true })
owner: User;
@OneToMany(() => ChannelMember, (member) => member.channel)
members: ChannelMember[];
@OneToMany(() => Message, (message) => message.channel)
messages: Message[];
@CreateDateColumn()
createdAt: Date;
}
// src/chat/entities/channel-member.entity.ts
@Entity('channel_members')
export class ChannelMember {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Channel, (channel) => channel.members)
channel: Channel;
@ManyToOne(() => User)
user: User;
@Column({ default: 'member' })
role: 'owner' | 'admin' | 'member';
@Column({ nullable: true })
mutedUntil: Date;
@Column({ default: false })
banned: boolean;
@CreateDateColumn()
joinedAt: Date;
}
// src/chat/entities/message.entity.ts
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Channel, (channel) => channel.messages)
channel: Channel;
@ManyToOne(() => User)
sender: User;
@Column('text')
content: string;
@CreateDateColumn()
createdAt: Date;
}
4.2 チャットサービス
// src/chat/chat.service.ts
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Channel } from './entities/channel.entity';
import { ChannelMember } from './entities/channel-member.entity';
import { Message } from './entities/message.entity';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class ChatService {
constructor(
@InjectRepository(Channel)
private channelRepository: Repository<Channel>,
@InjectRepository(ChannelMember)
private memberRepository: Repository<ChannelMember>,
@InjectRepository(Message)
private messageRepository: Repository<Message>,
private usersService: UsersService,
) {}
// チャンネル作成
async createChannel(
ownerId: number,
name: string,
type: string,
password?: string,
): Promise<Channel> {
const owner = await this.usersService.findById(ownerId);
const channel = this.channelRepository.create({
name,
type: type as any,
password: password ? await bcrypt.hash(password, 10) : null,
owner,
});
const savedChannel = await this.channelRepository.save(channel);
// オーナーをメンバーに追加
await this.memberRepository.save({
channel: savedChannel,
user: owner,
role: 'owner',
});
return savedChannel;
}
// チャンネル参加
async joinChannel(
userId: number,
channelId: number,
password?: string,
): Promise<void> {
const channel = await this.channelRepository.findOne({
where: { id: channelId },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
// パスワードチェック
if (channel.type === 'protected') {
if (!password || !await bcrypt.compare(password, channel.password)) {
throw new ForbiddenException('Invalid password');
}
}
// 既にメンバーかチェック
const existingMember = await this.memberRepository.findOne({
where: {
channel: { id: channelId },
user: { id: userId },
},
});
if (existingMember) {
if (existingMember.banned) {
throw new ForbiddenException('You are banned from this channel');
}
return; // 既にメンバー
}
const user = await this.usersService.findById(userId);
await this.memberRepository.save({
channel,
user,
role: 'member',
});
}
// メッセージ送信
async sendMessage(
userId: number,
channelId: number,
content: string,
): Promise<Message> {
// メンバーシップとミュート確認
const member = await this.getMember(channelId, userId);
if (!member) {
throw new ForbiddenException('Not a member of this channel');
}
if (member.mutedUntil && member.mutedUntil > new Date()) {
throw new ForbiddenException('You are muted');
}
const channel = await this.channelRepository.findOne({
where: { id: channelId },
});
const sender = await this.usersService.findById(userId);
const message = this.messageRepository.create({
channel,
sender,
content,
});
return this.messageRepository.save(message);
}
// メッセージ取得
async getMessages(
channelId: number,
limit = 50,
before?: number,
): Promise<Message[]> {
const query = this.messageRepository
.createQueryBuilder('message')
.leftJoinAndSelect('message.sender', 'sender')
.where('message.channelId = :channelId', { channelId })
.orderBy('message.createdAt', 'DESC')
.take(limit);
if (before) {
query.andWhere('message.id < :before', { before });
}
return query.getMany();
}
// 管理者機能
async kickUser(
adminId: number,
channelId: number,
targetId: number,
): Promise<void> {
await this.checkAdminPermission(adminId, channelId);
const targetMember = await this.getMember(channelId, targetId);
if (targetMember?.role === 'owner') {
throw new ForbiddenException('Cannot kick the owner');
}
await this.memberRepository.delete({
channel: { id: channelId },
user: { id: targetId },
});
}
async muteUser(
adminId: number,
channelId: number,
targetId: number,
duration: number, // 秒
): Promise<void> {
await this.checkAdminPermission(adminId, channelId);
const mutedUntil = new Date(Date.now() + duration * 1000);
await this.memberRepository.update(
{ channel: { id: channelId }, user: { id: targetId } },
{ mutedUntil },
);
}
async banUser(
adminId: number,
channelId: number,
targetId: number,
): Promise<void> {
await this.checkAdminPermission(adminId, channelId);
const targetMember = await this.getMember(channelId, targetId);
if (targetMember?.role === 'owner') {
throw new ForbiddenException('Cannot ban the owner');
}
await this.memberRepository.update(
{ channel: { id: channelId }, user: { id: targetId } },
{ banned: true },
);
}
// ヘルパー
private async getMember(
channelId: number,
userId: number,
): Promise<ChannelMember | null> {
return this.memberRepository.findOne({
where: {
channel: { id: channelId },
user: { id: userId },
},
});
}
private async checkAdminPermission(
userId: number,
channelId: number,
): Promise<void> {
const member = await this.getMember(channelId, userId);
if (!member || (member.role !== 'owner' && member.role !== 'admin')) {
throw new ForbiddenException('Admin permission required');
}
}
}
4.3 チャットゲートウェイ
// src/chat/chat.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { UseGuards } from '@nestjs/common';
import { WsJwtGuard } from '../auth/guards/ws-jwt.guard';
import { ChatService } from './chat.service';
import { UsersService } from '../users/users.service';
@WebSocketGateway({
namespace: 'chat',
cors: {
origin: process.env.FRONTEND_URL,
credentials: true,
},
})
export class ChatGateway {
@WebSocketServer()
server: Server;
constructor(
private chatService: ChatService,
private usersService: UsersService,
) {}
@UseGuards(WsJwtGuard)
@SubscribeMessage('chat:join')
async handleJoin(
@ConnectedSocket() client: Socket,
@MessageBody() data: { channelId: number; password?: string },
) {
try {
const userId = client.data.user.id;
await this.chatService.joinChannel(
userId,
data.channelId,
data.password,
);
client.join(`channel:${data.channelId}`);
// 参加通知
this.server.to(`channel:${data.channelId}`).emit('chat:user:joined', {
channelId: data.channelId,
user: client.data.user,
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('chat:leave')
async handleLeave(
@ConnectedSocket() client: Socket,
@MessageBody() data: { channelId: number },
) {
client.leave(`channel:${data.channelId}`);
this.server.to(`channel:${data.channelId}`).emit('chat:user:left', {
channelId: data.channelId,
userId: client.data.user.id,
});
return { success: true };
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('chat:message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: { channelId: number; content: string },
) {
try {
const userId = client.data.user.id;
// ブロックチェックを含むメッセージ送信
const message = await this.chatService.sendMessage(
userId,
data.channelId,
data.content,
);
// チャンネル全体に配信(ブロックしているユーザーはフロントエンドでフィルタ)
this.server.to(`channel:${data.channelId}`).emit('chat:message:new', {
id: message.id,
channelId: data.channelId,
sender: client.data.user,
content: message.content,
createdAt: message.createdAt,
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
@UseGuards(WsJwtGuard)
@SubscribeMessage('chat:typing')
handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { channelId: number },
) {
client.to(`channel:${data.channelId}`).emit('chat:typing', {
channelId: data.channelId,
user: client.data.user,
});
}
// 管理者コマンド
@UseGuards(WsJwtGuard)
@SubscribeMessage('chat:kick')
async handleKick(
@ConnectedSocket() client: Socket,
@MessageBody() data: { channelId: number; targetId: number },
) {
try {
await this.chatService.kickUser(
client.data.user.id,
data.channelId,
data.targetId,
);
this.server.to(`channel:${data.channelId}`).emit('chat:user:kicked', {
channelId: data.channelId,
targetId: data.targetId,
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
}
---
5. ゲームマッチング
5.1 マッチメイキングサービス
// src/game/matchmaking.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
interface QueuedPlayer {
id: number;
username: string;
rating: number;
joinedAt: Date;
socketId: string;
}
@Injectable()
export class MatchmakingService {
private queue: QueuedPlayer[] = [];
private readonly MATCH_RANGE = 200; // レーティング差
private readonly EXPAND_INTERVAL = 10000; // 10秒ごとに範囲拡大
constructor(private eventEmitter: EventEmitter2) {}
addToQueue(player: QueuedPlayer): void {
// 既にキューにいる場合は追加しない
if (this.queue.some(p => p.id === player.id)) {
return;
}
this.queue.push(player);
this.tryMatch();
}
removeFromQueue(playerId: number): void {
this.queue = this.queue.filter(p => p.id !== playerId);
}
private tryMatch(): void {
if (this.queue.length < 2) return;
// レーティングでソート
this.queue.sort((a, b) => a.rating - b.rating);
for (let i = 0; i < this.queue.length - 1; i++) {
const player1 = this.queue[i];
const player2 = this.queue[i + 1];
// 待機時間に応じて許容範囲を拡大
const waitTime1 = Date.now() - player1.joinedAt.getTime();
const waitTime2 = Date.now() - player2.joinedAt.getTime();
const expandFactor = Math.min(waitTime1, waitTime2) / this.EXPAND_INTERVAL;
const allowedDiff = this.MATCH_RANGE * (1 + expandFactor);
const ratingDiff = Math.abs(player1.rating - player2.rating);
if (ratingDiff <= allowedDiff) {
// マッチ成立
this.queue = this.queue.filter(
p => p.id !== player1.id && p.id !== player2.id
);
this.eventEmitter.emit('match.created', {
player1,
player2,
});
break;
}
}
}
getQueueStatus(playerId: number): { position: number; estimatedWait: number } | null {
const index = this.queue.findIndex(p => p.id === playerId);
if (index === -1) return null;
return {
position: index + 1,
estimatedWait: Math.max(0, (index * 30) -
(Date.now() - this.queue[index].joinedAt.getTime()) / 1000),
};
}
}
5.2 ゲーム状態管理
// src/game/game.state.ts
export interface Ball {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
export interface Paddle {
y: number;
width: number;
height: number;
}
export interface GameState {
id: string;
player1: {
id: number;
username: string;
paddle: Paddle;
score: number;
ready: boolean;
};
player2: {
id: number;
username: string;
paddle: Paddle;
score: number;
ready: boolean;
};
ball: Ball;
status: 'waiting' | 'countdown' | 'playing' | 'paused' | 'finished';
winner: number | null;
createdAt: Date;
startedAt: Date | null;
}
export class GameEngine {
private readonly WIDTH = 800;
private readonly HEIGHT = 600;
private readonly PADDLE_SPEED = 10;
private readonly BALL_SPEED = 7;
private readonly WIN_SCORE = 11;
constructor(private state: GameState) {}
update(): void {
if (this.state.status !== 'playing') return;
this.moveBall();
this.checkCollisions();
this.checkScore();
}
private moveBall(): void {
this.state.ball.x += this.state.ball.vx;
this.state.ball.y += this.state.ball.vy;
}
private checkCollisions(): void {
const ball = this.state.ball;
const paddle1 = this.state.player1.paddle;
const paddle2 = this.state.player2.paddle;
// 上下の壁
if (ball.y - ball.radius <= 0 || ball.y + ball.radius >= this.HEIGHT) {
ball.vy *= -1;
}
// Player1のパドル(左側)
if (ball.x - ball.radius <= paddle1.width) {
if (ball.y >= paddle1.y && ball.y <= paddle1.y + paddle1.height) {
ball.vx = Math.abs(ball.vx); // 右に跳ね返す
// パドルの当たった位置で角度を変える
const hitPos = (ball.y - paddle1.y) / paddle1.height;
ball.vy = (hitPos - 0.5) * this.BALL_SPEED * 2;
}
}
// Player2のパドル(右側)
if (ball.x + ball.radius >= this.WIDTH - paddle2.width) {
if (ball.y >= paddle2.y && ball.y <= paddle2.y + paddle2.height) {
ball.vx = -Math.abs(ball.vx); // 左に跳ね返す
const hitPos = (ball.y - paddle2.y) / paddle2.height;
ball.vy = (hitPos - 0.5) * this.BALL_SPEED * 2;
}
}
}
private checkScore(): void {
const ball = this.state.ball;
// Player2得点(ボールが左端を越えた)
if (ball.x < 0) {
this.state.player2.score++;
this.resetBall(-1);
}
// Player1得点(ボールが右端を越えた)
if (ball.x > this.WIDTH) {
this.state.player1.score++;
this.resetBall(1);
}
// 勝敗判定
if (this.state.player1.score >= this.WIN_SCORE) {
this.state.status = 'finished';
this.state.winner = this.state.player1.id;
} else if (this.state.player2.score >= this.WIN_SCORE) {
this.state.status = 'finished';
this.state.winner = this.state.player2.id;
}
}
private resetBall(direction: number): void {
this.state.ball = {
x: this.WIDTH / 2,
y: this.HEIGHT / 2,
vx: this.BALL_SPEED * direction,
vy: (Math.random() - 0.5) * this.BALL_SPEED,
radius: 10,
};
}
movePaddle(playerId: number, direction: 'up' | 'down'): void {
const paddle = playerId === this.state.player1.id
? this.state.player1.paddle
: this.state.player2.paddle;
const delta = direction === 'up' ? -this.PADDLE_SPEED : this.PADDLE_SPEED;
const newY = paddle.y + delta;
// 画面内に収める
paddle.y = Math.max(0, Math.min(this.HEIGHT - paddle.height, newY));
}
getState(): GameState {
return this.state;
}
}
---
まとめ
本章で学んだこと:
- NestJS基礎: モジュール、コントローラー、サービス
- 認証実装: OAuth 2.0、JWT、2FA
- ユーザー管理: CRUD、フレンド、ブロック
- チャット実装: チャンネル、メッセージ、WebSocket
- ゲームマッチング: キュー、レーティング
- ゲーム状態: 物理演算、衝突判定
次章では、フロントエンドの実装を学びます。