feat: 스티그마타 초기 커밋
This commit is contained in:
commit
6935ea0a78
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# 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__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# rpg-web
|
||||||
|
|
||||||
|
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/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
13
index.html
Normal file
13
index.html
Normal 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>스티그마타</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2994
package-lock.json
generated
Normal file
2994
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "rpg-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
30
src/App.vue
Normal file
30
src/App.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a0a0f;
|
||||||
|
color: #e0d0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: #0d0d1a; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #2a1a4e; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #5522aa; }
|
||||||
|
</style>
|
||||||
51
src/api.ts
Normal file
51
src/api.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const BASE = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const token = localStorage.getItem('rpg_token')
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path: string, body?: object) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.message || '오류가 발생했습니다.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path: string) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.message || '오류가 발생했습니다.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
register: (username: string, password: string) =>
|
||||||
|
post('/api/auth/register', { username, password }),
|
||||||
|
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
post('/api/auth/login', { username, password }),
|
||||||
|
|
||||||
|
me: () => get('/api/auth/me'),
|
||||||
|
|
||||||
|
rpgStart: () => post('/api/web/rpg/start'),
|
||||||
|
|
||||||
|
rpgAction: (choice: number) =>
|
||||||
|
post('/api/web/rpg/action', { choice }),
|
||||||
|
|
||||||
|
rpgStatus: () => get('/api/web/rpg/status'),
|
||||||
|
|
||||||
|
rpgReset: () => post('/api/web/rpg/reset'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpgResult {
|
||||||
|
text: string
|
||||||
|
choices: { text: string; [key: string]: unknown }[]
|
||||||
|
}
|
||||||
86
src/assets/base.css
Normal file
86
src/assets/base.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
6
src/assets/main.css
Normal file
6
src/assets/main.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
20
src/router/index.ts
Normal file
20
src/router/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import LoginView from '../views/LoginView.vue'
|
||||||
|
import GameView from '../views/GameView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/login' },
|
||||||
|
{ path: '/login', name: 'login', component: LoginView },
|
||||||
|
{ path: '/game', name: 'game', component: GameView, meta: { requiresAuth: true } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const token = localStorage.getItem('rpg_token')
|
||||||
|
if (to.meta.requiresAuth && !token) return '/login'
|
||||||
|
if (to.path === '/login' && token) return '/game'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
409
src/views/GameView.vue
Normal file
409
src/views/GameView.vue
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
<template>
|
||||||
|
<div class="game-wrap">
|
||||||
|
<header class="game-header">
|
||||||
|
<span class="header-title">⚔️ 스티그마타</span>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="username">{{ username }}</span>
|
||||||
|
<button class="btn-status" @click="showStatus = !showStatus" title="캐릭터 상태">📊</button>
|
||||||
|
<button class="btn-reset" @click="confirmReset" title="초기화">🔄</button>
|
||||||
|
<button class="btn-logout" @click="logout" title="로그아웃">🚪</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="status-panel" v-if="showStatus">
|
||||||
|
<pre class="status-text">{{ statusText || '상태를 불러오는 중...' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="game-main">
|
||||||
|
<div class="story-area" ref="storyRef">
|
||||||
|
<div v-for="(entry, i) in history" :key="i" class="story-entry" :class="entry.type">
|
||||||
|
<pre class="story-text">{{ entry.text }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading-dots">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="choices-area" v-if="!loading && choices.length > 0">
|
||||||
|
<button
|
||||||
|
v-for="(choice, i) in choices"
|
||||||
|
:key="i"
|
||||||
|
class="choice-btn"
|
||||||
|
@click="selectChoice(i + 1)"
|
||||||
|
>
|
||||||
|
<span class="choice-num">{{ numEmoji(i) }}</span>
|
||||||
|
<span class="choice-text">{{ choice.text }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="restart-area" v-if="!loading && choices.length === 0 && history.length > 0">
|
||||||
|
<button class="btn-restart" @click="startGame">다시 시작</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-overlay" v-if="resetConfirm" @click.self="resetConfirm = false">
|
||||||
|
<div class="modal">
|
||||||
|
<p>⚠️ 캐릭터를 초기화하면 모든 진행 상황이 삭제됩니다. 계속하시겠습니까?</p>
|
||||||
|
<div class="modal-btns">
|
||||||
|
<button @click="doReset" class="btn-danger">초기화</button>
|
||||||
|
<button @click="resetConfirm = false">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, type RpgResult } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref(localStorage.getItem('rpg_username') || '')
|
||||||
|
const history = ref<{ text: string; type: 'story' | 'choice' }[]>([])
|
||||||
|
const choices = ref<{ text: string }[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showStatus = ref(false)
|
||||||
|
const statusText = ref('')
|
||||||
|
const resetConfirm = ref(false)
|
||||||
|
const storyRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const NUM_EMOJIS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣']
|
||||||
|
function numEmoji(i: number) { return NUM_EMOJIS[i] || `${i+1}.` }
|
||||||
|
|
||||||
|
function scrollDown() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (storyRef.value) storyRef.value.scrollTop = storyRef.value.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(result: RpgResult) {
|
||||||
|
history.value.push({ text: result.text, type: 'story' })
|
||||||
|
choices.value = result.choices || []
|
||||||
|
scrollDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGame() {
|
||||||
|
loading.value = true
|
||||||
|
history.value = []
|
||||||
|
choices.value = []
|
||||||
|
try {
|
||||||
|
const result = await api.rpgStart()
|
||||||
|
applyResult(result)
|
||||||
|
} catch (e: any) {
|
||||||
|
history.value.push({ text: `❌ ${e.message}`, type: 'story' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectChoice(choice: number) {
|
||||||
|
const selected = choices.value[choice - 1]
|
||||||
|
if (!selected) return
|
||||||
|
history.value.push({ text: `▶ ${selected.text}`, type: 'choice' })
|
||||||
|
choices.value = []
|
||||||
|
loading.value = true
|
||||||
|
scrollDown()
|
||||||
|
try {
|
||||||
|
const result = await api.rpgAction(choice)
|
||||||
|
applyResult(result)
|
||||||
|
} catch (e: any) {
|
||||||
|
history.value.push({ text: `❌ ${e.message}`, type: 'story' })
|
||||||
|
choices.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const res = await api.rpgStatus()
|
||||||
|
statusText.value = res.text
|
||||||
|
} catch {
|
||||||
|
statusText.value = '상태를 불러올 수 없습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showStatus, (v) => { if (v) loadStatus() })
|
||||||
|
|
||||||
|
function confirmReset() { resetConfirm.value = true }
|
||||||
|
|
||||||
|
async function doReset() {
|
||||||
|
resetConfirm.value = false
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.rpgReset()
|
||||||
|
await startGame()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('rpg_token')
|
||||||
|
localStorage.removeItem('rpg_username')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(startGame)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0a0a0f;
|
||||||
|
color: #e0d0ff;
|
||||||
|
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #13131f;
|
||||||
|
border-bottom: 1px solid #2a1a4e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c8a8ff;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8877bb;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #8877bb;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header button:hover {
|
||||||
|
background: #2a1a4e;
|
||||||
|
color: #c8a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel {
|
||||||
|
background: #0d0d1a;
|
||||||
|
border-bottom: 1px solid #2a1a4e;
|
||||||
|
padding: 12px 20px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #c8a8ff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-entry.story .story-text {
|
||||||
|
background: #13131f;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #e0d0ff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-entry.choice .story-text {
|
||||||
|
text-align: right;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7744cc;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #5522aa;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices-area {
|
||||||
|
padding: 12px 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
background: #13131f;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
color: #e0d0ff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:hover {
|
||||||
|
background: #1e1030;
|
||||||
|
border-color: #7744cc;
|
||||||
|
color: #c8a8ff;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:active {
|
||||||
|
transform: translateX(0);
|
||||||
|
background: #2a1a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-num {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-text {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-area {
|
||||||
|
padding: 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restart {
|
||||||
|
background: linear-gradient(135deg, #5522aa, #7744cc);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 32px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #13131f;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
color: #e0d0ff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btns button {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
background: #0d0d1a;
|
||||||
|
color: #8877bb;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btns button:hover { background: #2a1a4e; color: #c8a8ff; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #3d0a1a !important;
|
||||||
|
border-color: #7a1a2e !important;
|
||||||
|
color: #ff6677 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover { background: #5a1020 !important; }
|
||||||
|
</style>
|
||||||
3
src/views/HomeView.vue
Normal file
3
src/views/HomeView.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
200
src/views/LoginView.vue
Normal file
200
src/views/LoginView.vue
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">⚔️</span>
|
||||||
|
<h1>스티그마타</h1>
|
||||||
|
<p class="subtitle">🌑 영원한 잿빛 하늘 아래의 이야기</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="{ active: mode === 'login' }" @click="mode = 'login'; error = ''">로그인</button>
|
||||||
|
<button :class="{ active: mode === 'register' }" @click="mode = 'register'; error = ''">회원가입</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="field">
|
||||||
|
<label>닉네임</label>
|
||||||
|
<input v-model="username" type="text" placeholder="닉네임을 입력하세요" maxlength="20" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>비밀번호</label>
|
||||||
|
<input v-model="password" type="password" placeholder="비밀번호를 입력하세요" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn-submit" :disabled="loading">
|
||||||
|
{{ loading ? '처리 중...' : mode === 'login' ? '로그인' : '회원가입' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const mode = ref<'login' | 'register'>('login')
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
if (!username.value.trim() || !password.value) {
|
||||||
|
error.value = '닉네임과 비밀번호를 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = mode.value === 'login'
|
||||||
|
? await api.login(username.value.trim(), password.value)
|
||||||
|
: await api.register(username.value.trim(), password.value)
|
||||||
|
localStorage.setItem('rpg_token', res.token)
|
||||||
|
localStorage.setItem('rpg_username', res.username)
|
||||||
|
router.push('/game')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0a0a0f;
|
||||||
|
background-image: radial-gradient(ellipse at top, #1a0a2e 0%, #0a0a0f 70%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: #13131f;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
box-shadow: 0 0 40px rgba(100, 0, 200, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c8a8ff;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6655a0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #6655a0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: #2a1a4e;
|
||||||
|
color: #c8a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8877bb;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
background: #0d0d1a;
|
||||||
|
border: 1px solid #2a1a4e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #e0d0ff;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
border-color: #7744cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: #3d3060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6677;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: -8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
background: linear-gradient(135deg, #5522aa, #7744cc);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
tsconfig.app.json
Normal file
18
tsconfig.app.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
tsconfig.node.json
Normal file
27
tsconfig.node.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user