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