v0.4
This commit is contained in:
@@ -517,6 +517,13 @@
|
||||
background: url('assets/card-frame.png') center / cover no-repeat;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-image-bg {
|
||||
position: absolute;
|
||||
inset: 16% 13% 13%;
|
||||
object-fit: cover;
|
||||
opacity: .35;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Inner content area sits on the lined paper, inside the ornate border */
|
||||
.card-inner {
|
||||
position: absolute;
|
||||
@@ -675,9 +682,19 @@
|
||||
<a href="games.html">Sessions</a>
|
||||
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:.8rem">
|
||||
<div style="display:flex;align-items:center;gap:.6rem">
|
||||
<select x-show="game && (cards.length || customDecks.length)"
|
||||
x-model="activeDeckId"
|
||||
style="background:transparent;border:1px solid var(--line);color:var(--muted);
|
||||
font:650 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
|
||||
padding:.45rem .6rem;cursor:pointer;appearance:none">
|
||||
<option value="">Default Cards</option>
|
||||
<template x-for="d in customDecks" :key="d.id">
|
||||
<option :value="d.id" x-text="d.name" style="background:var(--panel)"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button
|
||||
x-show="game && cards.length"
|
||||
x-show="game && (cards.length || customDecks.length)"
|
||||
class="edit-toggle locked"
|
||||
style="border-color:rgba(184,138,67,.35)"
|
||||
@click="drawCard()"
|
||||
@@ -718,6 +735,15 @@
|
||||
<div class="game-header-actions" x-show="editMode">
|
||||
<button class="btn sm" @click="openEditGame()">Edit Session Details</button>
|
||||
</div>
|
||||
<div class="game-meta-row" x-show="customDecks.length" style="margin-top:1rem;gap:.6rem">
|
||||
<span style="font:700 .65rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">Deck</span>
|
||||
<select x-model="activeDeckId" @change="saveDeckToGame()" style="background:var(--panel);border:1px solid var(--line);color:var(--text);font:700 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:.35rem .7rem;cursor:pointer;appearance:none">
|
||||
<option value="">Default Cards</option>
|
||||
<template x-for="d in customDecks" :key="d.id">
|
||||
<option :value="d.id" x-text="d.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -825,8 +851,11 @@
|
||||
</div>
|
||||
<div class="entry-title" x-text="item.title"></div>
|
||||
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
|
||||
<div class="entry-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode && !item.isAnchor">
|
||||
@@ -866,8 +895,11 @@
|
||||
</div>
|
||||
<div class="entry-title" x-text="item.title"></div>
|
||||
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
|
||||
<div class="entry-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode">
|
||||
@@ -910,8 +942,11 @@
|
||||
<a class="kw-usage" :href="'#entry-' + ref.id" x-text="ref.title"></a>
|
||||
</template>
|
||||
</div>
|
||||
<div class="entry-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode">
|
||||
@@ -967,6 +1002,9 @@
|
||||
<!-- Card modal -->
|
||||
<div class="card-overlay" x-show="showCard" @click.self="showCard = false" @keydown.escape.window="showCard = false">
|
||||
<div class="card-frame" x-show="drawnCard">
|
||||
<!-- Custom card image shown as subtle background within the inner frame -->
|
||||
<img x-show="drawnCard?.cardImage" class="card-image-bg"
|
||||
:src="'assets/deck-images/' + drawnCard?.cardImage" alt="" />
|
||||
<div class="card-inner">
|
||||
<div class="card-content">
|
||||
<div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div>
|
||||
@@ -1104,6 +1142,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Card Deck</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default deck for this session</label>
|
||||
<select x-model="activeForm.deck_id">
|
||||
<option value="">Default Cards (built-in)</option>
|
||||
<template x-for="d in customDecks" :key="d.id">
|
||||
<option :value="d.id" x-text="d.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="form-hint">Players will draw from this deck by default. Can be overridden per-session in the nav.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-error" x-show="formError" x-text="formError"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
||||
@@ -1361,6 +1413,11 @@
|
||||
saving: false,
|
||||
tlItems: [],
|
||||
_tlRows: { top: 1, bot: 1 },
|
||||
// Image decks
|
||||
deckImages: [],
|
||||
deckCache: {},
|
||||
customDecks: [], // full deck objects with cards
|
||||
activeDeckId: '', // '' = default cards.json
|
||||
// Live updates
|
||||
liveUpdate: false,
|
||||
// Card draw
|
||||
@@ -1398,6 +1455,7 @@
|
||||
return
|
||||
}
|
||||
this.loadCards()
|
||||
this.loadDecks()
|
||||
this.connectLive()
|
||||
const ok = await this.loadGame()
|
||||
if (!ok) { this.loading = false; return }
|
||||
@@ -1416,6 +1474,7 @@
|
||||
if (!r.ok) { this.error = 'Session not found'; return false }
|
||||
this.game = await r.json()
|
||||
document.title = this.game.title + ' — Orison Reach'
|
||||
if (this.game.deck_id) this.activeDeckId = this.game.deck_id
|
||||
return true
|
||||
} catch {
|
||||
this.error = 'Network error'
|
||||
@@ -1429,6 +1488,54 @@
|
||||
this.items = r.ok ? await r.json() : []
|
||||
} catch { this.items = [] }
|
||||
this.tlItems = this.computeLayout()
|
||||
this.buildDeckCache()
|
||||
},
|
||||
|
||||
async loadDecks() {
|
||||
try {
|
||||
const r = await fetch('/api/decks')
|
||||
if (!r.ok) return
|
||||
const decks = await r.json()
|
||||
this.customDecks = decks
|
||||
this.deckImages = decks.flatMap(d => (d.images || []).map(img => ({
|
||||
filename: img.filename, keywords: img.keywords || []
|
||||
})))
|
||||
this.buildDeckCache()
|
||||
} catch {}
|
||||
},
|
||||
|
||||
buildDeckCache() {
|
||||
if (!this.deckImages.length) return
|
||||
const cache = {}
|
||||
for (const item of this.items) {
|
||||
if (!item.env) cache[item.id] = this.pickDeckImage(item)
|
||||
}
|
||||
this.deckCache = cache
|
||||
},
|
||||
|
||||
// Deterministic seed from item ID so image never changes on re-render
|
||||
idSeed(id) {
|
||||
return id.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 0)
|
||||
},
|
||||
|
||||
pickDeckImage(item) {
|
||||
if (!this.deckImages.length) return null
|
||||
const tags = new Set()
|
||||
if (item.mood) tags.add(item.mood.toLowerCase())
|
||||
if (item.discipline) tags.add(item.discipline.toLowerCase())
|
||||
for (const m of (item.description || '').matchAll(/\*\*([^*\n]+)\*\*/g))
|
||||
tags.add(m[1].trim().toLowerCase())
|
||||
const scored = this.deckImages.map(img => ({
|
||||
img,
|
||||
score: img.keywords.filter(k => tags.has(k.toLowerCase())).length + 1
|
||||
}))
|
||||
const total = scored.reduce((s, x) => s + x.score, 0)
|
||||
let pick = (this.idSeed(item.id) % 10000) / 10000 * total
|
||||
for (const { img, score } of scored) {
|
||||
pick -= score
|
||||
if (pick <= 0) return img.filename
|
||||
}
|
||||
return scored[0].img.filename
|
||||
},
|
||||
|
||||
connectLive() {
|
||||
@@ -1456,6 +1563,35 @@
|
||||
},
|
||||
|
||||
drawCard() {
|
||||
// Custom deck selected?
|
||||
if (this.activeDeckId) {
|
||||
const deck = this.customDecks.find(d => d.id === this.activeDeckId)
|
||||
const pool = deck?.cards || []
|
||||
if (!pool.length) return
|
||||
const hasCast = this.cast().length > 0
|
||||
const hasKeyword = this.allKeywords().length > 0
|
||||
const hasProject = this.projects().length > 0
|
||||
const meets = c => {
|
||||
if (!c.requires?.length) return true
|
||||
return c.requires.every(r =>
|
||||
r==='cast'?hasCast:r==='keyword'?hasKeyword:r==='project'?hasProject:true)
|
||||
}
|
||||
const eligible = pool.filter(meets)
|
||||
const avail = eligible.filter(c => !this.recentCardIds.includes(c.id))
|
||||
const src = avail.length ? avail : eligible
|
||||
if (!src.length) return
|
||||
const picked = src[Math.floor(Math.random() * src.length)]
|
||||
const resolved = this.resolveDynamic(picked)
|
||||
// Pick random card image if the card has images
|
||||
if (picked.images?.length) {
|
||||
const img = picked.images[Math.floor(Math.random() * picked.images.length)]
|
||||
resolved.cardImage = img.filename
|
||||
}
|
||||
this.drawnCard = resolved
|
||||
this.recentCardIds = [...this.recentCardIds.slice(-9), picked.id]
|
||||
this.showCard = true
|
||||
return
|
||||
}
|
||||
if (!this.cards.length) return
|
||||
const hasCast = this.cast().length > 0
|
||||
const hasKeyword = this.allKeywords().length > 0
|
||||
@@ -1992,8 +2128,17 @@
|
||||
this.showModal = true
|
||||
},
|
||||
|
||||
async saveDeckToGame() {
|
||||
if (!this.game || !this.editMode) return
|
||||
await fetch('/api/games/' + this.slug, {
|
||||
method: 'PUT',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({ ...this.game, deck_id: this.activeDeckId })
|
||||
})
|
||||
},
|
||||
|
||||
openEditGame() {
|
||||
this.activeForm = Object.assign({}, this.game)
|
||||
this.activeForm = Object.assign({ deck_id: '' }, this.game)
|
||||
this.formError = null
|
||||
this.modalType = 'editGame'
|
||||
this.showModal = true
|
||||
@@ -2074,6 +2219,7 @@
|
||||
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
|
||||
this.game = data
|
||||
document.title = data.title + ' — Orison Reach'
|
||||
if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
|
||||
this.tlItems = this.computeLayout()
|
||||
this.closeModal()
|
||||
} catch { this.formError = 'Network error' }
|
||||
|
||||
Reference in New Issue
Block a user