109 lines
12 KiB
Vue
109 lines
12 KiB
Vue
<script setup lang="ts">
|
||
const { activeWorld, characters, roundNumber, history, currentAction, ready, scene } = useDemo()
|
||
const submitting = ref(false)
|
||
const forceOpen = ref(false)
|
||
const errorMessage = ref('')
|
||
const submittedActionIndex = ref<number | null>(null)
|
||
|
||
async function submitAction() {
|
||
if (!currentAction.value.trim() || submitting.value || ready.value) return
|
||
submitting.value = true
|
||
errorMessage.value = ''
|
||
const action = currentAction.value.trim()
|
||
try {
|
||
submittedActionIndex.value = history.value.push({ type: 'action', author: characters.value[0]?.name.toUpperCase() ?? 'PLAYER', body: action, meta: 'READY' }) - 1
|
||
ready.value = true
|
||
currentAction.value = ''
|
||
await new Promise(resolve => setTimeout(resolve, 650))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function editAction() {
|
||
const index = submittedActionIndex.value
|
||
if (index === null) return
|
||
const submitted = history.value[index]
|
||
if (submitted?.type === 'action') {
|
||
currentAction.value = submitted.body
|
||
history.value.splice(index, 1)
|
||
}
|
||
submittedActionIndex.value = null
|
||
ready.value = false
|
||
}
|
||
|
||
async function continueRound() {
|
||
if ((!ready.value && !forceOpen.value) || submitting.value) return
|
||
submitting.value = true
|
||
errorMessage.value = ''
|
||
try {
|
||
const resolved = await $fetch<{ narration: string; roll: string; outcome: string }>('/api/rounds/demo/resolve', {
|
||
method: 'POST', body: { action: history.value.filter(item => item.type === 'action').at(-1)?.body ?? 'I carefully investigate.' },
|
||
})
|
||
history.value.push({ type: 'roll', author: 'SYSTEM CHECK', body: resolved.roll, meta: resolved.outcome })
|
||
history.value.push({ type: 'narration', author: 'GROUNDKEEPER', body: resolved.narration, meta: `ROUND ${String(roundNumber.value).padStart(2, '0')} · RESOLVED` })
|
||
scene.value = resolved.narration
|
||
roundNumber.value += 1
|
||
ready.value = false
|
||
forceOpen.value = false
|
||
submittedActionIndex.value = null
|
||
} catch {
|
||
errorMessage.value = 'The Groundkeeper could not resolve this round. Your action is still ready; please try again.'
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<AppShell :section="activeWorld.title.toUpperCase()">
|
||
<div class="game">
|
||
<aside class="party-panel">
|
||
<div class="panel-label"><span>PARTY / 02</span><button>+</button></div>
|
||
<article v-for="character in characters" :key="character.id" :class="['party-member', character.controller]">
|
||
<div class="portrait">{{ character.name.split(' ').map(n=>n[0]).join('') }}</div>
|
||
<div><h3>{{ character.name }}</h3><p>{{ character.controller === 'human' ? 'YOU · READY' : 'AI COMPANION · WAITING' }}</p></div>
|
||
<i :class="character.controller" />
|
||
</article>
|
||
<div class="character-sheet">
|
||
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
|
||
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> DEF</span></div>
|
||
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
||
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="chronicle noise">
|
||
<header class="scene-head"><div><p>BLACK MERIDIAN RELAY</p><h1>THE ARCHIVE BELOW</h1></div><span>ROUND {{ String(roundNumber).padStart(2,'0') }}</span></header>
|
||
<div class="timeline">
|
||
<article v-for="(item,index) in history" :key="index" :class="item.type">
|
||
<div class="timeline-meta"><small>{{ item.author }}</small><span>{{ item.meta }}</span></div>
|
||
<p>{{ item.body }}</p>
|
||
</article>
|
||
</div>
|
||
<div class="action-dock">
|
||
<div class="round-state"><span><i :class="{on:ready}" /> {{ ready ? 'YOUR ACTION IS READY' : 'WAITING FOR YOUR ACTION' }}</span><span>ROOK-7 ACTS AFTER HUMANS</span></div>
|
||
<textarea v-model="currentAction" :disabled="ready" maxlength="2000" placeholder="What does Mara do?" aria-label="Your action" @keydown.meta.enter="submitAction" />
|
||
<p v-if="errorMessage" class="action-error" role="alert">{{ errorMessage }}</p>
|
||
<div class="dock-actions"><small>{{ currentAction.length }} / 2000 · ⌘ ENTER TO READY</small><button v-if="!ready" :disabled="submitting || !currentAction.trim()" @click="submitAction">MARK READY <span>→</span></button><button v-else class="edit" :disabled="submitting" @click="editAction">EDIT ACTION</button></div>
|
||
</div>
|
||
</main>
|
||
|
||
<aside class="round-panel">
|
||
<div class="panel-label"><span>ROUND CONTROL</span><button>•••</button></div>
|
||
<div class="readiness"><p><b>1 / 1</b> HUMANS READY</p><div><i :class="{filled:ready}" /></div></div>
|
||
<div class="intent-preview"><small>MARA VALE</small><p>{{ history.filter(i=>i.type==='action').at(-1)?.body || 'No action submitted yet.' }}</p><span>{{ ready ? 'READY' : 'DRAFT' }}</span></div>
|
||
<div class="ai-turn"><small>AI TURN ORDER</small><div><b>01</b><span>Rook-7<p>Acts after human intentions</p></span></div></div>
|
||
<label class="force"><input v-model="forceOpen" type="checkbox"><span><b>CONTINUE WITHOUT WAITING</b><small>Owner override. Missing human characters may be delegated to AI.</small></span></label>
|
||
<button class="resolve" :disabled="submitting || (!ready && !forceOpen)" @click="continueRound">{{ submitting ? 'RESOLVING…' : 'RESOLVE ROUND' }} <span>↗</span></button>
|
||
<div class="round-note"><i /> All rolls and mechanical changes are resolved by the server and written to the audit log.</div>
|
||
</aside>
|
||
</div>
|
||
</AppShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.game{height:calc(100vh - 76px);display:grid;grid-template-columns:270px minmax(420px,1fr) 300px;overflow:hidden}.party-panel,.round-panel{background:#0c0c0b;overflow-y:auto}.party-panel{border-right:1px solid var(--line)}.round-panel{border-left:1px solid var(--line)}.panel-label{height:52px;padding:0 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);font:600 8px var(--mono);letter-spacing:.14em;color:var(--muted)}.panel-label button{border:0;background:none;color:var(--muted)}.party-member{display:grid;grid-template-columns:38px 1fr 8px;gap:12px;align-items:center;padding:18px;border-bottom:1px solid var(--line)}.portrait{display:grid;place-items:center;width:38px;height:38px;background:#191917;border:1px solid #32322e;font:600 9px var(--mono)}.party-member h3{margin:0 0 5px;font:600 10px var(--display)}.party-member p{margin:0;font:500 7px var(--mono);color:var(--muted)}.party-member>i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.party-member>i.human{background:var(--acid);box-shadow:0 0 10px var(--acid)}.character-sheet{padding:28px 18px}.character-sheet>small,.ai-turn>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}.character-sheet h2{font:600 20px var(--display);margin:14px 0 8px}.character-sheet>p{font-size:11px;line-height:1.5;color:var(--muted)}.vitals{display:flex;gap:8px;margin:24px 0}.vitals span{flex:1;padding:12px 8px;background:#121210;border:1px solid var(--line);font:500 7px var(--mono);color:var(--muted)}.vitals b{color:var(--ink);font-size:13px}.abilities{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line)}.abilities span{padding:9px;background:#10100f;text-align:center}.abilities small{display:block;text-transform:uppercase;font:500 7px var(--mono);color:var(--muted)}.abilities b{font:600 12px var(--mono)}details{margin-top:24px;border-top:1px solid var(--line);padding-top:16px}summary{font:600 8px var(--mono);cursor:pointer}details ul{padding-left:18px;color:var(--muted);font-size:11px}.chronicle{position:relative;overflow-y:auto;padding-bottom:220px}.scene-head{position:sticky;top:0;z-index:5;padding:30px clamp(28px,5vw,76px) 20px;display:flex;justify-content:space-between;align-items:end;background:linear-gradient(#0a0a0a 75%,transparent)}.scene-head p{font:500 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.scene-head h1{font:600 clamp(24px,3vw,39px) var(--display);letter-spacing:-.05em;margin:8px 0}.scene-head>span{font:500 9px var(--mono);color:var(--muted)}.timeline{max-width:780px;margin:20px auto;padding:0 34px}.timeline article{padding:26px 0;border-bottom:1px solid var(--line)}.timeline-meta{display:flex;justify-content:space-between;align-items:center}.timeline-meta small{font:600 8px var(--mono);letter-spacing:.14em;color:var(--acid)}.timeline-meta span{font:500 7px var(--mono);color:var(--muted)}.timeline p{font:400 15px/1.75 var(--body);color:#d1d1ca}.timeline .action{margin-left:50px;border-left:2px solid #494943;padding-left:20px}.timeline .action .timeline-meta small{color:var(--ink)}.timeline .roll{margin:18px 0;padding:14px 17px;background:#121210;border:1px solid var(--line)}.timeline .roll p{font:500 11px var(--mono);margin:8px 0;color:#aaa}.action-dock{position:fixed;bottom:0;left:270px;right:300px;z-index:10;padding:14px 22px 20px;background:rgba(12,12,11,.95);backdrop-filter:blur(15px);border-top:1px solid var(--line)}.round-state{display:flex;justify-content:space-between;margin-bottom:10px;font:500 7px var(--mono);letter-spacing:.12em;color:var(--muted)}.round-state i{display:inline-block;width:6px;height:6px;border-radius:50%;background:#555;margin-right:6px}.round-state i.on{background:var(--acid)}.action-dock textarea{width:100%;height:74px;resize:none;padding:15px;background:#11110f;border:1px solid #383833;color:var(--ink);font:13px/1.5 var(--body);box-sizing:border-box}.dock-actions{display:flex;justify-content:space-between;align-items:center;margin-top:9px}.dock-actions small{font:500 7px var(--mono);color:var(--muted)}.dock-actions button,.resolve{min-height:38px;border:0;background:var(--acid);padding:0 17px;font:600 8px var(--mono);letter-spacing:.1em}.dock-actions .edit{background:transparent;border:1px solid var(--line);color:var(--ink)}button:disabled{opacity:.35}.readiness,.intent-preview,.ai-turn,.force,.round-note{padding:22px;border-bottom:1px solid var(--line)}.readiness p{font:500 8px var(--mono);color:var(--muted)}.readiness b{font-size:18px;color:var(--ink)}.readiness div{height:5px;background:#252522}.readiness div i{display:block;width:100%;height:100%;background:#333}.readiness div i.filled{background:var(--acid)}.intent-preview{position:relative}.intent-preview small{font:600 8px var(--mono)}.intent-preview p{font-size:11px;line-height:1.6;color:var(--muted)}.intent-preview>span{font:600 7px var(--mono);color:var(--acid)}.ai-turn div{display:flex;gap:12px;margin-top:18px}.ai-turn div>b{font:500 8px var(--mono);color:var(--acid)}.ai-turn span{font:600 10px var(--display)}.ai-turn p{font:400 9px var(--body);color:var(--muted)}.force{display:flex;gap:12px;cursor:pointer}.force input{accent-color:var(--acid)}.force span{display:flex;flex-direction:column}.force b{font:600 8px var(--mono)}.force small{margin-top:7px;font:400 9px/1.45 var(--body);color:var(--muted)}.resolve{display:block;width:calc(100% - 44px);height:48px;margin:22px}.round-note{font-size:9px;line-height:1.5;color:var(--muted)}.round-note i{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--acid);margin-right:7px}@media(max-width:1050px){.game{grid-template-columns:220px 1fr}.round-panel{display:none}.action-dock{left:220px;right:0}}@media(max-width:700px){.game{display:block;height:auto}.party-panel{display:none}.chronicle{min-height:calc(100vh - 76px)}.action-dock{left:0}.timeline{padding:0 20px}.scene-head{padding-left:20px;padding-right:20px}.timeline .action{margin-left:16px}}
|
||
.action-error{margin:0 0 9px;color:#ff9d85;font:500 9px/1.4 var(--mono)}
|
||
</style>
|