diff --git a/client/package-lock.json b/client/package-lock.json index 5eb777b..3b5f26f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "colyseus.js": "latest", + "lokijs": "^1.5.12", "vue": "latest", "vue-router": "latest" }, @@ -1270,6 +1271,12 @@ "he": "bin/he" } }, + "node_modules/lokijs": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz", + "integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/client/package.json b/client/package.json index d8bb7a2..ca0ef51 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,8 @@ "dependencies": { "vue": "latest", "vue-router": "latest", - "colyseus.js": "latest" + "colyseus.js": "latest", + "lokijs": "^1.5.12" }, "devDependencies": { "@vitejs/plugin-vue": "latest", @@ -20,4 +21,4 @@ "vite": "latest", "vue-tsc": "latest" } -} \ No newline at end of file +} diff --git a/client/src/services/colyseus.ts b/client/src/services/colyseus.ts index 0a3da5a..9a6b6dc 100644 --- a/client/src/services/colyseus.ts +++ b/client/src/services/colyseus.ts @@ -1,5 +1,6 @@ import { Client, Room } from "colyseus.js"; import { ref, Ref } from "vue"; +import { localDB } from "./db"; export interface PlayerData { sessionId: string; @@ -24,12 +25,18 @@ class ColyseusService { private client: Client; private currentRoom: Room | null = null; private apiBase: string; + private desiredName: string | null = null; + private nameRetryCount: number = 0; + private nameRetryTimer: any = null; + private readonly LS_KEY_NAME = "snatch.player.name"; + private readonly LS_KEY_COLOR = "snatch.player.color"; public lobbyRoom: Ref = ref(null); public gameRoom: Ref = ref(null); public playerName: Ref = ref(""); public sessionId: Ref = ref(""); public playerColor: Ref = ref("#667eea"); + public nameConfirmed: Ref = ref(false); constructor() { const defaultHost = typeof window !== "undefined" ? window.location.hostname : "localhost"; @@ -42,6 +49,13 @@ class ColyseusService { const httpProtocol = typeof window !== "undefined" && window.location.protocol === "https:" ? "https" : "http"; const apiFallback = `${httpProtocol}://${defaultHost}:${defaultPort}/api`; this.apiBase = (import.meta.env as any).VITE_API_URL || apiFallback; + // Hydrate from localStorage for immediate UI + try { + const savedName = typeof window !== 'undefined' ? (window.localStorage.getItem(this.LS_KEY_NAME) || "") : ""; + const savedColor = typeof window !== 'undefined' ? (window.localStorage.getItem(this.LS_KEY_COLOR) || "") : ""; + if (savedName) this.playerName.value = savedName; + if (savedColor) this.playerColor.value = savedColor; + } catch {} } async joinLobby(): Promise { @@ -49,19 +63,77 @@ class ColyseusService { const room = await this.client.joinOrCreate("lobby"); this.lobbyRoom.value = room; this.currentRoom = room; + // Require explicit confirmation each time we join the lobby + this.nameConfirmed.value = false; + // Clear any pending name retry from previous sessions + this.desiredName = null; + this.nameRetryCount = 0; + if (this.nameRetryTimer) { clearTimeout(this.nameRetryTimer); this.nameRetryTimer = null; } - room.onMessage("welcome", (data) => { + room.onMessage("welcome", async (data) => { this.sessionId.value = data.sessionId; - this.playerName.value = data.assignedName; if (data.color) this.playerColor.value = data.color; + // Initialize local DB and optionally auto-apply saved profile + try { + await localDB.init(); + const profile = localDB.getLocalPlayer(); + // Apply saved color silently + if (profile?.color && profile.color !== this.playerColor.value) { + this.setPlayerColor(profile.color); + } + let candidateName = profile?.name || ""; + if (!candidateName) { + try { candidateName = typeof window !== 'undefined' ? (window.localStorage.getItem(this.LS_KEY_NAME) || "") : ""; } catch {} + } + if (candidateName) { + this.playerName.value = candidateName; + try { localDB.setName(candidateName); } catch {} + this.claimSavedName(candidateName); + } + } catch (e) { + console.warn("Local DB init failed", e); + // Fallback purely to localStorage + try { + const candidateName = typeof window !== 'undefined' ? (window.localStorage.getItem(this.LS_KEY_NAME) || "") : ""; + if (candidateName) { + this.playerName.value = candidateName; + this.claimSavedName(candidateName); + } + } catch {} + } }); room.onMessage("nameUpdated", (data) => { this.playerName.value = data.name; + try { localDB.setName(data.name); } catch {} + try { if (typeof window !== 'undefined') window.localStorage.setItem(this.LS_KEY_NAME, data.name || ""); } catch {} + this.nameConfirmed.value = true; + + // If we are trying to reclaim a saved name and got a suffixed one, retry briefly. + if (this.desiredName) { + const desired = (this.desiredName || "").trim().toLowerCase(); + const got = (data.name || "").trim().toLowerCase(); + if (desired && desired !== got && this.nameRetryCount < 2) { + const attempt = ++this.nameRetryCount; + if (this.nameRetryTimer) clearTimeout(this.nameRetryTimer); + this.nameRetryTimer = setTimeout(() => { + // Attempt again; if the previous session has now left, exact name may be free + this.setPlayerName(this.desiredName || ""); + }, attempt * 400); + return; + } + // Either matched desired or exceeded retries; stop retrying + this.desiredName = null; + this.nameRetryCount = 0; + if (this.nameRetryTimer) { clearTimeout(this.nameRetryTimer); this.nameRetryTimer = null; } + } }); room.onMessage("colorUpdated", (data) => { - if (data?.color) this.playerColor.value = data.color; + if (data?.color) { + this.playerColor.value = data.color; + try { localDB.setColor(data.color); } catch {} + } }); return room; @@ -74,12 +146,26 @@ class ColyseusService { async setPlayerName(name: string): Promise { if (this.lobbyRoom.value) { this.lobbyRoom.value.send("setName", name); + try { if (typeof window !== 'undefined') window.localStorage.setItem(this.LS_KEY_NAME, name || ""); } catch {} } } + // Attempt to reclaim the saved name with a short retry window to avoid race + private claimSavedName(name: string): void { + this.desiredName = name; + this.nameRetryCount = 0; + if (this.nameRetryTimer) { clearTimeout(this.nameRetryTimer); this.nameRetryTimer = null; } + // Small delay to let any previous session fully leave the lobby + this.nameRetryTimer = setTimeout(() => { + this.setPlayerName(name); + this.nameConfirmed.value = true; + }, 150); + } + async setPlayerColor(color: string): Promise { if (this.lobbyRoom.value) { this.lobbyRoom.value.send("setColor", color); + try { if (typeof window !== 'undefined') window.localStorage.setItem(this.LS_KEY_COLOR, color || ""); } catch {} } } diff --git a/client/src/services/db.ts b/client/src/services/db.ts new file mode 100644 index 0000000..6b11ef4 --- /dev/null +++ b/client/src/services/db.ts @@ -0,0 +1,90 @@ +import Loki from "lokijs"; + +export interface LocalPlayerDoc { + id: string; // fixed id for local profile + name: string; + color: string; + stats: { + totalClicks: number; + gamesPlayed: number; + wins: number; + losses: number; + }; +} + +class LocalDBService { + private db: Loki | null = null; + private players: Collection | null = null; + private initialized = false; + + async init(): Promise { + if (this.initialized) return; + return new Promise((resolve) => { + // In browser, Loki uses localStorage adapter by default; no need to pass one. + this.db = new Loki("snatchgame.local.db", { + autoload: true, + autoloadCallback: () => { + this.players = this.db!.getCollection("players"); + if (!this.players) { + this.players = this.db!.addCollection("players", { unique: ["id"] }); + } + // Ensure local profile exists + if (!this.players.by("id", "local")) { + this.players.insert({ + id: "local", + name: "", + color: "#667eea", + stats: { totalClicks: 0, gamesPlayed: 0, wins: 0, losses: 0 } + }); + } + this.initialized = true; + this.db!.saveDatabase(() => resolve()); + }, + autosave: true, + autosaveInterval: 2000 + } as any); + }); + } + + private get col(): Collection { + if (!this.players) throw new Error("LocalDB not initialized"); + return this.players; + } + + getLocalPlayer(): LocalPlayerDoc { + const doc = this.col.by("id", "local"); + return doc as LocalPlayerDoc; + } + + setName(name: string): void { + const doc = this.getLocalPlayer(); + doc.name = name; + this.col.update(doc); + this.db?.saveDatabase(); + } + + setColor(color: string): void { + const doc = this.getLocalPlayer(); + doc.color = color; + this.col.update(doc); + this.db?.saveDatabase(); + } + + incClicks(delta = 1): void { + const doc = this.getLocalPlayer(); + doc.stats.totalClicks = (doc.stats.totalClicks || 0) + delta; + this.col.update(doc); + this.db?.saveDatabase(); + } + + recordGame(result: "win" | "loss"): void { + const doc = this.getLocalPlayer(); + doc.stats.gamesPlayed = (doc.stats.gamesPlayed || 0) + 1; + if (result === "win") doc.stats.wins = (doc.stats.wins || 0) + 1; + else doc.stats.losses = (doc.stats.losses || 0) + 1; + this.col.update(doc); + this.db?.saveDatabase(); + } +} + +export const localDB = new LocalDBService(); diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index 6028090..ce40178 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -77,6 +77,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'; import { useRouter } from 'vue-router'; import { colyseusService } from '../services/colyseus'; +import { localDB } from '../services/db'; import { getStateCallbacks } from 'colyseus.js'; const router = useRouter(); @@ -197,6 +198,7 @@ function handleClick() { if (gameStatus.value !== 'playing') return; colyseusService.sendClick(); + try { localDB.incClicks(1); } catch {} isClicking.value = true; clearTimeout(clickTimeout); diff --git a/client/src/views/Lobby.vue b/client/src/views/Lobby.vue index f6aee25..c4c8f93 100644 --- a/client/src/views/Lobby.vue +++ b/client/src/views/Lobby.vue @@ -29,10 +29,12 @@
- +
Antes de jugar, presiona "Set Name" para confirmar tu nombre.
+
Nombre confirmado ✔
@@ -45,7 +47,8 @@ v-for="room in availableRooms" :key="room.roomId" class="room-card" - @click="joinRoom(room.roomId)" + :class="{ disabled: !nameConfirmed }" + @click="nameConfirmed ? joinRoom(room.roomId) : null" >
Room #{{ room.roomId.slice(0, 6) }} @@ -78,11 +81,12 @@