diff --git a/assets/clean-pony2fix.glb b/assets/clean-pony2fix.glb index 2cf27a8..ce35692 100644 Binary files a/assets/clean-pony2fix.glb and b/assets/clean-pony2fix.glb differ diff --git a/migrations/001-initial.sql b/migrations/001-initial.sql index ddcbb9f..8a07582 100644 --- a/migrations/001-initial.sql +++ b/migrations/001-initial.sql @@ -2,6 +2,31 @@ -- Up -------------------------------------------------------------------------------- +PRAGMA foreign_keys = ON; + +CREATE TABLE User ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT +); + +CREATE TABLE Pony ( + id INTEGER PRIMARY KEY, + display_name TEXT, + position TEXT, + rotation TEXT, + character TEXT, + created_at TEXT, + user_id INTEGER, + FOREIGN KEY(user_id) REFERENCES User(user_id) +); + -------------------------------------------------------------------------------- -- Down -------------------------------------------------------------------------------- + +DROP TABLE User; +DROP TABLE Pony; + +PRAGMA foreign_keys = OFF; diff --git a/src/client/game.ts b/src/client/game.ts index 3ea43f1..a3742a7 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -56,7 +56,7 @@ export class Game { '/assets/terrain/decoration/flowers01.png', ); - await PonyModelLoader.getInstance().loadPonyModel(); + await PonyModelLoader.getInstance().initialize(); await PonyEyes.getInstance().initialize(); await this.world.initialize(); @@ -74,13 +74,6 @@ export class Game { this.party = (localStorage.getItem('party')?.split('|') || []).filter( (item) => item, ); - - const character = localStorage.getItem('character') - ? JSON.parse(localStorage.getItem('character')) - : {}; - if (character) { - this.character = character; - } // end of this.chat.registerSendFunction((message) => { @@ -167,6 +160,7 @@ export class Game { public dispose() { this.players.forEach((player) => { + player.dispose(); this.renderer.scene.remove(player.container); }); @@ -192,10 +186,17 @@ export class Game { console.log('connected'); }); + this.socket.on('error.duplicate', () => { + this._loading.showError( + 'Error: You are already connected on another device!', + ); + }); + this.socket.on('me', (user) => { console.log(user); if (!user) { + this._loading.showError('Error: You need to log in!'); window.location.href = '/login'; return; } @@ -211,8 +212,11 @@ export class Game { ); const player = Player.fromUser(user, this.renderer.scene); - player.setCharacter(this.character); + user.character && player.setCharacter(user.character); + user.position && player.container.position.fromArray(user.position); + user.rotation && player.container.rotation.fromArray(user.rotation); player.setHeightSource(this.world); + this.players.push(player); this.player = player; this.thirdPersonCamera = new ThirdPersonCamera( @@ -357,7 +361,6 @@ export class Game { this.character = { ...this.character, color }; this.player.setColor(color); this.socket.emit('character', this.character); - localStorage.setItem('character', JSON.stringify(this.character)); } if (message.startsWith('!eyecolor')) { diff --git a/src/client/object/player.ts b/src/client/object/player.ts index 751fd1b..5c97afc 100644 --- a/src/client/object/player.ts +++ b/src/client/object/player.ts @@ -151,7 +151,7 @@ export class Player extends PonyEntity { this._direction.x = 0; } - if (this.keydownMap[' '] && !this._prevKeydownMap[' '] && this.onFloor) { + if (this.keydownMap[' '] && !this._prevKeydownMap[' ']) { this.jump(); } diff --git a/src/client/object/resource/loading-manager.ts b/src/client/object/resource/loading-manager.ts index d9ae66e..52e4d26 100644 --- a/src/client/object/resource/loading-manager.ts +++ b/src/client/object/resource/loading-manager.ts @@ -61,4 +61,10 @@ export class LoadingManagerWrapper { this._status.innerText = 'Connected!'; this.element.classList.add('loading--complete'); } + + public showError(message: string) { + this.element.classList.remove('loading--complete'); + this.element.classList.add('loading--error'); + this._status.innerText = message; + } } diff --git a/src/client/object/resource/pony-loader.ts b/src/client/object/resource/pony-loader.ts index 9a7277c..56b09cb 100644 --- a/src/client/object/resource/pony-loader.ts +++ b/src/client/object/resource/pony-loader.ts @@ -18,7 +18,7 @@ export class PonyModelLoader { public ponyModel!: THREE.Group; public animations!: THREE.AnimationClip[]; - loadPonyModel(): Promise { + initialize(): Promise { return new Promise((resolve, reject) => { // Load a glTF resource loader.load( diff --git a/src/client/scss/index.scss b/src/client/scss/index.scss index e182f76..8b2a5d4 100644 --- a/src/client/scss/index.scss +++ b/src/client/scss/index.scss @@ -233,6 +233,12 @@ body { &.loading--complete { opacity: 0; } + + &.loading--error { + .loading__bar { + display: none; + } + } } &__bar { diff --git a/src/server/game.ts b/src/server/game.ts index 1bda4f0..6cda4ed 100644 --- a/src/server/game.ts +++ b/src/server/game.ts @@ -2,6 +2,7 @@ import { Server, Socket } from 'socket.io'; import { RequestHandler } from 'express'; import { IcyNetUser } from '../common/types/user'; import { CharacterPacket, PositionUpdatePacket } from '../common/types/packet'; +import { Persistence } from './object/persistence'; const PLACEHOLDER_USER = (socket: Socket): IcyNetUser => { const randomName = `player-${socket.id.substring(0, 8)}`; @@ -17,6 +18,7 @@ const PLACEHOLDER_USER = (socket: Socket): IcyNetUser => { export class Game { private _connections: Socket[] = []; private _changedPlayers: number[] = []; + private _db = new Persistence(); constructor(private io: Server, private session: RequestHandler) {} @@ -29,6 +31,8 @@ export class Game { } async initialize() { + await this._db.initialize(); + this.io.use((socket, next) => this.session(socket.request as any, {} as any, next as any), ); @@ -43,7 +47,7 @@ export class Game { if ( user && - this._connections.find((entry) => entry.data.user.id === user.id) + this._connections.find((entry) => entry.data.user?.id === user.id) ) { socket.emit('error.duplicate'); return; @@ -51,8 +55,6 @@ export class Game { this._connections.push(socket); - socket.emit('me', publicUserInfo); - if (user) { socket.broadcast.emit('playerjoin', publicUserInfo); @@ -66,25 +68,11 @@ export class Game { character: {}, }; - socket.emit( - 'players', - this._connections - .filter((player) => player.data.user) - .map((conn) => ({ - ...this.mapPlayer(conn.data.user), - ...conn.data.playerinfo, - })), - ); - socket.on('move', (packet) => { socket.data.playerinfo = { ...socket.data.playerinfo, ...packet, }; - - // if (!this._changedPlayers.includes(socket.data.user.id)) { - // this._changedPlayers.push(socket.data.user.id); - // } }); socket.on('character', (info: CharacterPacket) => { @@ -103,6 +91,46 @@ export class Game { const message = raw.trim().substring(0, 280); this.io.emit('chat', { sender: publicUserInfo, message }); }); + + this._db.upsertUser(user).then((user) => { + this._db.getUserPony(user).then((pony) => { + if (pony) { + socket.data.playerinfo.character = + (pony.character as CharacterPacket) || {}; + + socket.data.playerinfo.position = pony.position; + socket.data.playerinfo.rotation = pony.rotation; + } else { + this._db.upsertPony(user, socket.data.playerinfo); + } + + socket.emit('me', { + ...publicUserInfo, + character: socket.data.playerinfo.character, + position: pony?.position, + rotation: pony?.rotation, + }); + + socket.emit( + 'players', + this._connections + .filter((player) => player.data.user) + .map((conn) => ({ + ...this.mapPlayer(conn.data.user), + ...conn.data.playerinfo, + })), + ); + + if (pony) { + this.io.emit('playercharacter', { + id: socket.data.user.id, + ...socket.data.playerinfo.character, + }); + } + }); + }); + } else { + socket.emit('me', null); } socket.on('disconnect', () => { @@ -110,6 +138,7 @@ export class Game { if (user) { this.io.emit('playerleave', publicUserInfo); + this._db.upsertPony(user, socket.data.playerinfo); } }); }); diff --git a/src/server/object/persistence.ts b/src/server/object/persistence.ts new file mode 100644 index 0000000..62abe1e --- /dev/null +++ b/src/server/object/persistence.ts @@ -0,0 +1,139 @@ +import { join } from 'path'; +import sqlite3 from 'sqlite3'; +import { open, Database } from 'sqlite'; +import { IcyNetUser } from '../../common/types/user'; +import { DatabasePony, DatabaseUser } from '../types/database'; + +export class Persistence { + private db!: Database; + + constructor(private _store = join(process.cwd(), 'ponies.db')) {} + + async initialize(): Promise { + this.db = await open({ + filename: this._store, + driver: sqlite3.cached.Database, + }); + + await this.db.migrate(); + } + + async upsertUser(user: IcyNetUser): Promise { + const existing = await this.db.get( + `SELECT * FROM User WHERE id = ?`, + user.id, + ); + if (existing) { + if (existing.display_name !== user.display_name) { + existing.display_name = user.display_name; + await this.db.run(`UPDATE User SET display_name = ? WHERE id = ?`, [ + user.display_name, + user.id, + ]); + } + return existing; + } + + const time = new Date().toISOString(); + await this.db.run( + `INSERT INTO User (id, username, display_name, created_at) VALUES (?,?,?,?)`, + [user.id, user.username, user.display_name, time], + ); + return { ...user, created_at: time }; + } + + async upsertPony( + user: IcyNetUser, + data: DatabasePony, + ): Promise { + const pony = await this.db.get( + 'SELECT * FROM Pony WHERE user_id = ?', + [user.id], + ); + + const updateData = this.serializePony({ ...pony, ...data }); + + if (pony) { + await this.db.run( + `UPDATE Pony SET display_name = ?, position = ?, rotation = ?, character = ? WHERE user_id = ?`, + [ + user.display_name, + updateData.position, + updateData.rotation, + updateData.character, + user.id, + ], + ); + return this.deserializePony(updateData); + } + + updateData.created_at = new Date().toISOString(); + updateData.user_id = user.id; + + const keys = this.filterPonyKeys(updateData); + const insert = Array.from({ length: keys.length }, () => '?').join(','); + + await this.db.run( + `INSERT INTO Pony (${keys.join(',')}) VALUES (${insert})`, + keys.map((key) => (updateData as any)[key]), + ); + + return this.deserializePony(updateData); + } + + async getUserPony(user: IcyNetUser): Promise { + const result = await this.db.get( + 'SELECT * FROM Pony WHERE user_id = ?', + user.id, + ); + + return result ? this.deserializePony(result) : null; + } + + private serializePony(data: DatabasePony): DatabasePony { + return { + ...data, + position: Array.isArray(data.position) + ? JSON.stringify(data.position) + : data.position, + rotation: Array.isArray(data.rotation) + ? JSON.stringify(data.rotation) + : data.rotation, + character: + typeof data.character !== 'string' + ? JSON.stringify(data.character) + : data.character, + }; + } + + private deserializePony(data: DatabasePony): DatabasePony { + return { + ...data, + position: Array.isArray(data.position) + ? data.position + : JSON.parse(data.position as string), + rotation: Array.isArray(data.rotation) + ? data.rotation + : JSON.parse(data.rotation as string), + character: + typeof data.character !== 'string' + ? data.character + : JSON.parse(data.character as string), + }; + } + + private filterPonyKeys(data: DatabasePony): string[] { + const keys = [ + 'display_name', + 'position', + 'rotation', + 'character', + 'created_at', + 'user_id', + ]; + return Object.keys(data).reduce( + (list, current) => (keys.includes(current) ? [...list, current] : list), + [], + ); + } +} diff --git a/src/server/types/database.ts b/src/server/types/database.ts new file mode 100644 index 0000000..c4960f9 --- /dev/null +++ b/src/server/types/database.ts @@ -0,0 +1,16 @@ +import { CharacterPacket } from '../../common/types/packet'; +import { IcyNetUser } from '../../common/types/user'; + +export interface DatabaseUser extends IcyNetUser { + created_at: string; +} + +export interface DatabasePony { + id?: number; + display_name?: string; + position?: string | number[]; + rotation?: string | number[]; + character?: string | CharacterPacket; + created_at?: string; + user_id?: number; +}