first commit

This commit is contained in:
Pedro Pérez 2025-12-27 23:03:51 +01:00
commit 31d5fe4e23
12 changed files with 3765 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
.vscode/

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# impostor
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

3143
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "impostor",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.25"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.2",
"daisyui": "^5.5.14",
"tailwindcss": "^4.1.18",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

292
src/App.vue Normal file
View File

@ -0,0 +1,292 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import "./style.css";
import { categories } from "./categories.js";
const gameState = ref("CATEGORY_SELECT"); // CATEGORY_SELECT, SETUP, PASSING, REVEALING, PLAYING
const selectedCategory = ref(null);
const players = ref([]);
const newPlayerName = ref("");
const currentPlayerIndex = ref(0);
const secretWord = ref("");
const impostorWord = ref("");
const impostorIndex = ref(null);
onMounted(() => {
const savedPlayers = localStorage.getItem("impostor_players");
if (savedPlayers) {
players.value = JSON.parse(savedPlayers);
}
});
watch(
players,
(newPlayers) => {
localStorage.setItem("impostor_players", JSON.stringify(newPlayers));
},
{ deep: true },
);
const selectCategory = (category) => {
selectedCategory.value = category;
gameState.value = "SETUP";
};
const addPlayer = () => {
if (newPlayerName.value.trim()) {
players.value.push({ name: newPlayerName.value.trim(), role: "Civil" });
newPlayerName.value = "";
}
};
const removePlayer = (index) => {
players.value.splice(index, 1);
};
const startGame = () => {
if (players.value.length < 3) return;
const shuffled = [...players.value];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
players.value = shuffled;
impostorIndex.value = Math.floor(Math.random() * players.value.length);
const randomPairIndex = Math.floor(
Math.random() * selectedCategory.value.words.length,
);
const pair = selectedCategory.value.words[randomPairIndex];
secretWord.value = pair.civil;
impostorWord.value = pair.impostor;
currentPlayerIndex.value = 0;
gameState.value = "PASSING";
};
const showRole = () => {
gameState.value = "REVEALING";
};
const nextTurn = () => {
if (currentPlayerIndex.value < players.value.length - 1) {
currentPlayerIndex.value++;
gameState.value = "PASSING";
} else {
gameState.value = "PLAYING";
}
};
const resetGame = () => {
gameState.value = "CATEGORY_SELECT";
selectedCategory.value = null;
currentPlayerIndex.value = 0;
};
const fullReset = () => {
if (confirm("¿Seguro que quieres borrar todos los jugadores?")) {
players.value = [];
gameState.value = "CATEGORY_SELECT";
selectedCategory.value = null;
}
};
</script>
<template>
<div class="min-h-screen bg-base-200 flex flex-col items-center p-4 pt-8">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<div v-if="gameState === 'CATEGORY_SELECT'" class="w-full space-y-4">
<h2 class="card-title text-2xl font-bold justify-center">
Elige una categoría
</h2>
<p class="text-sm opacity-70">
Selecciona el tema de las palabras para esta partida.
</p>
<div class="grid grid-cols-1 gap-3 w-full">
<button
v-for="cat in categories"
:key="cat.name"
@click="selectCategory(cat)"
class="btn btn-primary w-full"
>
{{ cat.name }}
</button>
</div>
<div class="divider"></div>
<button @click="fullReset" class="btn btn-error w-full">
Borrar todos los datos
</button>
</div>
<div v-if="gameState === 'SETUP'" class="w-full space-y-4">
<h2 class="card-title text-2xl font-bold justify-center">
Jugadores
</h2>
<div class="badge badge-primary">{{ selectedCategory?.name }}</div>
<div class="form-control w-full">
<div class="join w-full">
<input
v-model="newPlayerName"
@keyup.enter="addPlayer"
type="text"
placeholder="Nombre del jugador"
class="input input-bordered join-item w-full"
/>
<button @click="addPlayer" class="btn btn-primary join-item">
Añadir
</button>
</div>
</div>
<div class="divider">Lista ({{ players.length }})</div>
<ul class="w-full space-y-2 max-h-60 overflow-y-auto">
<li
v-for="(player, index) in players"
:key="index"
class="flex justify-between items-center bg-base-300 p-3 rounded-lg"
>
<span class="font-medium">{{ player.name }}</span>
<button
@click="removePlayer(index)"
class="btn btn-ghost btn-xs text-error"
>
</button>
</li>
</ul>
<div class="card-actions w-full pt-4 flex-col gap-2">
<button
@click="startGame"
class="btn btn-primary w-full font-bold text-lg"
:disabled="players.length < 3"
>
Empezar partida
</button>
<p v-if="players.length < 3" class="text-xs text-info mt-2">
Mínimo 3 jugadores para jugar.
</p>
</div>
</div>
<!-- PASSING: Turno de pasar el móvil -->
<div v-if="gameState === 'PASSING'" class="space-y-6">
<h2 class="text-xl">Pásale el móvil a...</h2>
<h1
class="text-4xl font-black text-primary uppercase wrap-break-word"
>
{{ players[currentPlayerIndex].name }}
</h1>
<div class="alert bg-base-200">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span class="text-xs text-left"
>Asegúrate de que nadie más mire la pantalla.</span
>
</div>
<div class="card-actions justify-center">
<button @click="showRole" class="btn btn-primary btn-lg w-full">
Ver mi palabra
</button>
</div>
</div>
<!-- REVEALING: Mostrar el rol -->
<div v-if="gameState === 'REVEALING'" class="space-y-6 w-full">
<!-- Si es impostor muestra su palabra 'pista' -->
<div
v-if="currentPlayerIndex === impostorIndex"
class="space-y-4 py-8 bg-error/10 border-2 border-error rounded-box"
>
<div class="badge badge-error font-bold">ERES EL IMPOSTOR</div>
<h2 class="text-xl font-bold opacity-70">Tu palabra secreta es:</h2>
<h1 class="text-4xl font-black text-error uppercase tracking-wider">
{{ impostorWord }}
</h1>
<p class="text-xs opacity-70 px-4">
No eres un civil. ¡Miente para que no te pillen!
</p>
</div>
<!-- Si es civil muestra la palabra real -->
<div
v-else
class="space-y-4 py-8 bg-success/10 border-2 border-success rounded-box"
>
<div class="badge badge-success font-bold text-white">
ERES CIVIL
</div>
<h2 class="text-xl font-bold opacity-70">Tu palabra secreta es:</h2>
<h1
class="text-4xl font-black text-success uppercase tracking-wider"
>
{{ secretWord }}
</h1>
<p class="text-xs opacity-70 px-4">
Memorízala y encuentra al intruso entre vosotros.
</p>
</div>
<div class="card-actions pt-4">
<button @click="nextTurn" class="btn btn-neutral w-full text-lg">
Entendido, ocultar
</button>
</div>
</div>
<!-- PLAYING: El juego en curso -->
<div v-if="gameState === 'PLAYING'" class="space-y-6">
<h2 class="text-3xl font-bold text-success">¡A jugar!</h2>
<div class="stats shadow w-full">
<div class="stat place-items-center">
<div class="stat-title">Categoría</div>
<div class="stat-value text-xl">{{ selectedCategory?.name }}</div>
</div>
</div>
<p class="text-lg px-4">
Empezad a hacer preguntas sobre vuestra palabra para descubrir quién
tiene la diferente.
</p>
<div class="divider"></div>
<div class="card-actions pt-4">
<button @click="resetGame" class="btn btn-outline btn-error w-full">
Nueva partida
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
/* Estilos globales si fueran necesarios, pero usamos Tailwind y DaisyUI */
</style>

182
src/categories.js Normal file
View File

@ -0,0 +1,182 @@
export const categories = [
{
name: 'Lugares',
words: [
{ civil: 'Playa', impostor: 'Piscina' },
{ civil: 'Escuela', impostor: 'Biblioteca' },
{ civil: 'Cine', impostor: 'Teatro' },
{ civil: 'Hospital', impostor: 'Farmacia' },
{ civil: 'Restaurante', impostor: 'Cafetería' },
{ civil: 'Gimnasio', impostor: 'Estadio' },
{ civil: 'Aeropuerto', impostor: 'Estación de Tren' },
{ civil: 'Supermercado', impostor: 'Mercadillo' },
{ civil: 'Castillo', impostor: 'Palacio' },
{ civil: 'Bosque', impostor: 'Selva' },
{ civil: 'Museo', impostor: 'Galería de Arte' },
{ civil: 'Hotel', impostor: 'Hostal' },
{ civil: 'Zoológico', impostor: 'Granja' },
{ civil: 'Iglesia', impostor: 'Catedral' },
{ civil: 'Parque', impostor: 'Jardín' },
{ civil: 'Discoteca', impostor: 'Bar' },
{ civil: 'Cárcel', impostor: 'Comisaría' },
{ civil: 'Desierto', impostor: 'Sabana' },
{ civil: 'Montaña', impostor: 'Volcán' },
{ civil: 'Oficina', impostor: 'Despacho' }
]
},
{
name: 'Comida',
words: [
{ civil: 'Pizza', impostor: 'Hamburguesa' },
{ civil: 'Helado', impostor: 'Yogur' },
{ civil: 'Tacos', impostor: 'Burrito' },
{ civil: 'Sushi', impostor: 'Arroz' },
{ civil: 'Pastel', impostor: 'Galleta' },
{ civil: 'Manzana', impostor: 'Pera' },
{ civil: 'Macarrones', impostor: 'Espaguetis' },
{ civil: 'Tortilla', impostor: 'Huevos Revueltos' },
{ civil: 'Paella', impostor: 'Risotto' },
{ civil: 'Chocolate', impostor: 'Turrón' },
{ civil: 'Sopa', impostor: 'Puré' },
{ civil: 'Ensalada', impostor: 'Verduras' },
{ civil: 'Cerveza', impostor: 'Vino' },
{ civil: 'Zumo', impostor: 'Refresco' },
{ civil: 'Pan', impostor: 'Tostada' },
{ civil: 'Queso', impostor: 'Mantequilla' },
{ civil: 'Fresas', impostor: 'Cerezas' },
{ civil: 'Salchicha', impostor: 'Chorizo' },
{ civil: 'Croissant', impostor: 'Ensaimada' },
{ civil: 'Café', impostor: 'Té' }
]
},
{
name: 'Animales',
words: [
{ civil: 'Perro', impostor: 'Lobo' },
{ civil: 'Gato', impostor: 'Tigre' },
{ civil: 'Caballo', impostor: 'Burro' },
{ civil: 'Delfín', impostor: 'Tiburón' },
{ civil: 'Águila', impostor: 'Halcón' },
{ civil: 'León', impostor: 'Pantera' },
{ civil: 'Elefante', impostor: 'Mamut' },
{ civil: 'Serpiente', impostor: 'Lagarto' },
{ civil: 'Abeja', impostor: 'Avispa' },
{ civil: 'Pingüino', impostor: 'Pato' },
{ civil: 'Cocodrilo', impostor: 'Caimán' },
{ civil: 'Conejo', impostor: 'Liebre' },
{ civil: 'Mariposa', impostor: 'Polilla' },
{ civil: 'Rata', impostor: 'Ratón' },
{ civil: 'Toro', impostor: 'Vaca' },
{ civil: 'Mono', impostor: 'Gorila' },
{ civil: 'Camello', impostor: 'Dromedario' },
{ civil: 'Gallina', impostor: 'Pavo' },
{ civil: 'Oso', impostor: 'Panda' },
{ civil: 'Hormiga', impostor: 'Escarabajo' }
]
},
{
name: 'Objetos',
words: [
{ civil: 'Silla', impostor: 'Taburete' },
{ civil: 'Lápiz', impostor: 'Bolígrafo' },
{ civil: 'Móvil', impostor: 'Tablet' },
{ civil: 'Vaso', impostor: 'Taza' },
{ civil: 'Gafas', impostor: 'Lupa' },
{ civil: 'Reloj', impostor: 'Cronómetro' },
{ civil: 'Martillo', impostor: 'Maza' },
{ civil: 'Cuchara', impostor: 'Tenedor' },
{ civil: 'Libro', impostor: 'Revista' },
{ civil: 'Cámara', impostor: 'Vídeo' },
{ civil: 'Llave', impostor: 'Candado' },
{ civil: 'Puerta', impostor: 'Ventana' },
{ civil: 'Almohada', impostor: 'Cojín' },
{ civil: 'Mesa', impostor: 'Escritorio' },
{ civil: 'Cama', impostor: 'Sofá' },
{ civil: 'Espejo', impostor: 'Cristal' },
{ civil: 'Botella', impostor: 'Jarra' },
{ civil: 'Maleta', impostor: 'Mochila' },
{ civil: 'Paraguas', impostor: 'Impermeable' },
{ civil: 'Escoba', impostor: 'Fregona' }
]
},
{
name: 'Deportes',
words: [
{ civil: 'Fútbol', impostor: 'Baloncesto' },
{ civil: 'Tenis', impostor: 'Pádel' },
{ civil: 'Natación', impostor: 'Waterpolo' },
{ civil: 'Boxeo', impostor: 'Kárate' },
{ civil: 'Ciclismo', impostor: 'Motociclismo' },
{ civil: 'Golf', impostor: 'Minigolf' },
{ civil: 'Esquí', impostor: 'Snowboard' },
{ civil: 'Voleibol', impostor: 'Balonmano' },
{ civil: 'Rugby', impostor: 'Fútbol Americano' },
{ civil: 'Surf', impostor: 'Windsurf' },
{ civil: 'Atletismo', impostor: 'Gimnasia' },
{ civil: 'Béisbol', impostor: 'Cricket' },
{ civil: 'Escalada', impostor: 'Senderismo' },
{ civil: 'Pesca', impostor: 'Caza' },
{ civil: 'Yoga', impostor: 'Pilates' }
]
},
{
name: 'Profesiones',
words: [
{ civil: 'Bombero', impostor: 'Policía' },
{ civil: 'Médico', impostor: 'Enfermero' },
{ civil: 'Cocinero', impostor: 'Camarero' },
{ civil: 'Profesor', impostor: 'Alumno' },
{ civil: 'Pintor', impostor: 'Escultor' },
{ civil: 'Astronauta', impostor: 'Piloto' },
{ civil: 'Mecánico', impostor: 'Fontanero' },
{ civil: 'Actor', impostor: 'Cantante' },
{ civil: 'Juez', impostor: 'Abogado' },
{ civil: 'Arquitecto', impostor: 'Ingeniero' },
{ civil: 'Veterinario', impostor: 'Zoólogo' },
{ civil: 'Periodista', impostor: 'Escritor' },
{ civil: 'Peluquero', impostor: 'Maquillador' },
{ civil: 'Soldado', impostor: 'Guardia' },
{ civil: 'Carpintero', impostor: 'Albañil' }
]
},
{
name: 'Ropa',
words: [
{ civil: 'Camiseta', impostor: 'Camisa' },
{ civil: 'Pantalón', impostor: 'Bermuda' },
{ civil: 'Zapato', impostor: 'Bota' },
{ civil: 'Calcetines', impostor: 'Medias' },
{ civil: 'Bufanda', impostor: 'Pañuelo' },
{ civil: 'Gorro', impostor: 'Sombrero' },
{ civil: 'Chaqueta', impostor: 'Abrigo' },
{ civil: 'Cinturón', impostor: 'Tirantes' },
{ civil: 'Guantes', impostor: 'Manoplas' },
{ civil: 'Vestido', impostor: 'Falda' }
]
},
{
name: 'Vehículos',
words: [
{ civil: 'Coche', impostor: 'Camión' },
{ civil: 'Autobús', impostor: 'Tren' },
{ civil: 'Helicóptero', impostor: 'Avión' },
{ civil: 'Barco', impostor: 'Submarino' },
{ civil: 'Moto', impostor: 'Bicicleta' },
{ civil: 'Taxi', impostor: 'Uber' },
{ civil: 'Cohete', impostor: 'Satélite' },
{ civil: 'Patinete', impostor: 'Monopatín' }
]
},
{
name: 'Instrumentos',
words: [
{ civil: 'Guitarra', impostor: 'Bajo' },
{ civil: 'Violín', impostor: 'Violonchelo' },
{ civil: 'Piano', impostor: 'Órgano' },
{ civil: 'Trompeta', impostor: 'Saxofón' },
{ civil: 'Batería', impostor: 'Tambor' },
{ civil: 'Flauta', impostor: 'Clarinete' },
{ civil: 'Arpa', impostor: 'Lira' }
]
}
]

4
src/main.js Normal file
View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

2
src/style.css Normal file
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

20
vite.config.js Normal file
View File

@ -0,0 +1,20 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueDevTools from "vite-plugin-vue-devtools";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
host: true,
allowedHosts: true,
},
});