classe et espece

This commit is contained in:
2026-02-16 19:58:56 +04:00
parent b949b2f343
commit 9941256cc8
4 changed files with 412 additions and 31 deletions

View File

@@ -52,16 +52,19 @@ const personnages = defineCollection({
schema: z
.object({
nom: z.string(),
titre: z.string().optional(),
espece: z.string(),
niveau_global: z.number().default(1),
classes_detail: z
.array(
classes_detail: z.array(
z.object({
nom: z.string(),
niveau: z.number(),
}),
)
.default([]),
),
// Ajout des stats et listes
stats: z.record(z.number()).optional(),
equipement: z.array(z.string()).default([]),
traits: z.array(z.string()).default([]),
})
.passthrough(),
});

View File

@@ -1,10 +1,43 @@
---
nom: "G'Mas"
espece: "grung"
titre: "Gueux des Mers Arides et Sableuses d'Outre-Tombe"
espece: "Grung"
niveau_global: 2
maitrise: 2
inspiration: true
classes_detail:
- nom: "moine"
- nom: "Moine"
niveau: 1
stats:
force: 10
dexterite: 16
constitution: 14
intelligence: 12
sagesse: 14
charisme: 8
sauvegardes: ["dexterite", "sagesse"]
traits_espece: ["Peau toxique", "Saut de grenouille", "Amphibie", "Vigilance Arboricole"]
capacites_classe: ["Méditation", "Arts Martiaux", "Marchand de sable"]
armes: ["Souffle-Quart"]
outils: ["Matériel d'alchimiste", "Tenue de rechange", "Sac à dos", "Sac de couchage", "Gamelle", "Boîte à Amadou", "10 torches", "10 jours de rations", "Outre", "15m de cordes" ]
sorts: ["Sommeil (Marchand de Sable)"]
description_physique: "Un Grung à la peau tannée par le sable, portant un sablier en bandoulière et des vêtements de voyage usés mais fonctionnels."
---
Fille de la lune et du savoir ancestral.
Permettez-moi de commencer par mon titre, celui qui m'a été donné dans mon ancienne patrie, car il en dit plus sur moi. Je suis **G'Mas**, le **Gueux des Mers Arides et Sableuses d'Outre-Tombe**.
D'où je viens, là où mon peuple de batraciens anthropomorphes existe, l'ordre établi n'est pas le vôtre. Là-bas, l'eau n'est qu'un souvenir lointain. Nos mers sont faites de sable. Cependant, un sable sombre, dépourvu de toute essence vitale, a fait son apparition. Pour mon peuple, ce fut un fléau, une abomination...
L'eau n'est qu'un souvenir que chaque grain crie à ceux qui peuvent l'entendre. Ce sable est la matière même de nos rêves les plus profonds.
## Le Pèlerinage du Sable
Mon voyage a commencé parce que ce granulé ancien, encore plus vieux que notre peuple, prend de plus en plus de place et menace notre survie. Je suis un pèlerin, un collecteur, un **Marchand de Sable** dont la tâche est de s'assurer que les cycles de sommeil et d'éveil de mon peuple puissent se poursuivre, en cherchant la source de ce fléau ou un remède.
C'est du sable que je tire toutes mes capacités :
* **Conteur :** Le sable est un catalyseur de sommeil et nous n'arrivons pas à nous endormir sans une histoire.
* **Navigateur expert :** La marche et les bonds sur les dunes mouvantes demandent une connaissance sans faille de la topographie.
> "Mon métier est Marchand de Sable, mais avec un peu d'art et d'attention, je passe volontiers à Marchand de Rêves pour vous offrir un repos profond et réparateur."
J'ai toujours sur moi mon petit kit d'herboristerie et, plus important encore, un **sablier**. Il me permet de définir la granularité et de classer les sables nouveaux que je rencontre selon leur couleur et leur teneur.

View File

@@ -14,7 +14,7 @@ const SPECTRUM = {
primal: "rgba(42, 157, 143, 1)",
hybride: "rgba(100, 100, 100, 1)",
// Spectres (Espèces) - Opacité 0.25
// Spectres (Espèces) - Opacité 0.4 (pour une meilleure visibilité)
solaire: "rgba(200, 155, 60, 0.4)",
elementaire: "rgba(0, 171, 255, 0.4)",
terrestre: "rgba(139, 69, 19, 0.4)",
@@ -29,13 +29,13 @@ const classData = personnages.reduce((acc, p) => {
);
const spectreKey = pEspeceData?.data.spectre?.toLowerCase();
// On itère sur chaque classe du personnage pour qu'il apparaisse partout
p.data.classes_detail.forEach((c) => {
if (!c.nom) return;
const key = c.nom.toLowerCase();
if (!acc[key]) acc[key] = [];
acc[key].push({
id: p.id, // ID nécessaire pour le lien
nom: p.data.nom,
niveau_classe: c.niveau,
colorSpectre: SPECTRUM[spectreKey] || "rgba(200, 200, 200, 0.5)",
@@ -50,9 +50,7 @@ const especeData = personnages.reduce((acc, p) => {
const key = p.data.espece.toLowerCase();
if (!acc[key]) acc[key] = [];
// On prend la première classe de la liste comme classe principale
const mainClassName = p.data.classes_detail?.[0]?.nom;
const pClassData = classes.find(
(cl) =>
cl.id.toLowerCase() === mainClassName?.toLowerCase() ||
@@ -61,6 +59,7 @@ const especeData = personnages.reduce((acc, p) => {
const encreKey = pClassData?.data.encre?.toLowerCase();
acc[key].push({
id: p.id, // ID nécessaire pour le lien
nom: p.data.nom,
niveau: p.data.niveau_global || 1,
colorEncre: SPECTRUM[encreKey] || "#ffffff",
@@ -96,7 +95,8 @@ const especeData = personnages.reduce((acc, p) => {
</h3>
<div class="bubble-container">
{charactersInClass.map((p) => (
<div
<a
href={`/personnages/${p.id}`}
class="notification-bubble bubble-species-border"
style={`--p-color: ${p.colorSpectre}`}
>
@@ -104,7 +104,7 @@ const especeData = personnages.reduce((acc, p) => {
<span class="p-lvl">
Niv.{p.niveau_classe}
</span>
</div>
</a>
))}
</div>
<p>{charClass.data.description}</p>
@@ -145,7 +145,8 @@ const especeData = personnages.reduce((acc, p) => {
<h3>{espece.data.title}</h3>
<div class="bubble-container">
{charactersInEspece.map((p) => (
<div
<a
href={`/personnages/${p.id}`}
class="notification-bubble bubble-class-border"
style={`--p-color: ${p.colorEncre}`}
>
@@ -153,7 +154,7 @@ const especeData = personnages.reduce((acc, p) => {
<span class="p-lvl">
Niv.{p.niveau}
</span>
</div>
</a>
))}
</div>
<p>{espece.data.description}</p>
@@ -210,7 +211,6 @@ const especeData = personnages.reduce((acc, p) => {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.class-card {
background-color: white;
padding: 2rem 1.5rem;
@@ -221,7 +221,6 @@ const especeData = personnages.reduce((acc, p) => {
justify-content: space-between;
transition: all 0.3s ease;
}
.dynamic-title {
color: var(--accent-color) !important;
font-family: "Playfair Display", serif;
@@ -230,7 +229,6 @@ const especeData = personnages.reduce((acc, p) => {
.species-card {
background-color: var(--bg-color) !important;
}
.notification-bubble {
background: #1a1a1a !important;
color: white !important;
@@ -242,20 +240,21 @@ const especeData = personnages.reduce((acc, p) => {
align-items: center;
gap: 5px;
border: 1px solid rgba(255, 255, 255, 0.1);
text-decoration: none; /* Important pour les liens */
transition: transform 0.2s ease;
}
.notification-bubble:hover {
transform: translateY(-2px);
filter: brightness(1.2);
}
/* Correction Bordure BAS (Espèce) : On ajoute un shadow interne pour renforcer la couleur à 0.25 */
.bubble-species-border {
background-color: var(--p-color) !important;
border-bottom: 3px solid var(--p-color) !important;
box-shadow: inset 0 -2px 0 0 var(--p-color);
}
/* Correction Bordure HAUT (Classe) */
.bubble-class-border {
border-top: 3px solid var(--p-color) !important;
}
.p-lvl {
font-size: 0.6rem;
opacity: 0.7;
@@ -270,18 +269,16 @@ const especeData = personnages.reduce((acc, p) => {
margin-bottom: 1.5rem;
min-height: 30px;
}
.select-button {
font-family: "Cinzel", serif;
text-decoration: none;
padding: 0.5rem 1rem;
border: 1px solid var(--p-color);
color: var(--p-color);
border: 1px solid var(--accent-color);
color: var(--accent-color);
transition: all 0.3s;
}
.select-button:hover {
background: var(--accent-color);
border: 1px solid var(--p-color);
color: white;
}
.species-button {

View File

@@ -0,0 +1,348 @@
---
import { getCollection, render } from "astro:content";
import GameLayout from "../../layouts/GameLayout.astro";
export async function getStaticPaths() {
const personnages = await getCollection("personnages");
return personnages.map((p) => ({
params: { id: p.id },
props: { p },
}));
}
const { p } = Astro.props;
const { Content } = await render(p);
// Logique métier
const getMod = (score) => Math.floor((score - 10) / 2);
const formatMod = (mod) => (mod >= 0 ? `+${mod}` : mod);
const statsOrder = [
"force",
"dexterite",
"constitution",
"intelligence",
"sagesse",
"charisme",
];
const classesLabel = p.data.classes_detail
?.map((c) => `${c.nom} (Niv.${c.niveau})`)
.join(" / ");
---
<GameLayout title={p.data.nom}>
<article class="character-sheet">
<header class="post-header">
<a href="/creation" class="back-nav">← Retour à la création</a>
<div class="header-badges">
<div class="mastery">
Maîtrise <span>+{p.data.maitrise || 2}</span>
</div>
{
p.data.inspiration && (
<div class="inspiration">✧ Inspiration</div>
)
}
</div>
<h1>{p.data.nom}</h1>
{p.data.titre && <p class="titre-honorifique">{p.data.titre}</p>}
<p class="meta">{p.data.espece} • {classesLabel}</p>
</header>
<div class="visual-and-stats">
<div class="canvas-area">
<div id="character-canvas-container">
<span>Canvas Illustration</span>
</div>
{
p.data.description_physique && (
<p class="physique">{p.data.description_physique}</p>
)
}
</div>
<div class="stats-grid">
{
statsOrder.map((s) => {
const val = p.data.stats?.[s] || 10;
const isProf = p.data.sauvegardes?.includes(s);
return (
<div
class={`stat-tag ${isProf ? "proficient" : ""}`}
>
<span class="s-label">{s.slice(0, 3)}</span>
<span class="s-mod">
{formatMod(getMod(val))}
</span>
<span class="s-val">({val})</span>
</div>
);
})
}
</div>
</div>
<hr class="separator" />
<div class="technical-info">
<section>
<h3>Traits & Capacités</h3>
<div class="tags-row">
{
p.data.traits_espece?.map((t) => (
<span class="tag">{t}</span>
))
}
{
p.data.capacites_classe?.map((c) => (
<span class="tag alt">{c}</span>
))
}
</div>
</section>
<section>
<h3>Équipement & Sorts</h3>
<div class="tags-row">
{
p.data.armes?.map((a) => (
<span class="tag weapon">{a}</span>
))
}
{
p.data.sorts?.map((s) => (
<span class="tag spell">{s}</span>
))
}
</div>
</section>
</div>
<div class="story-divider"><span>Histoire & Hebel</span></div>
<div class="prose-content">
<Content />
</div>
</article>
</GameLayout>
<style>
/* BASE (Mobile First) */
.character-sheet {
background: white;
border: 1px solid #dcd0b9;
border-radius: 8px;
padding: 1.5rem 1rem; /* Padding réduit type Journal */
max-width: 850px;
margin: 1rem auto;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.03);
}
.back-nav {
font-family: "Cinzel", serif;
font-size: 0.8rem;
color: #b8860b;
text-decoration: none;
display: block;
margin-bottom: 1rem;
}
.post-header {
text-align: center;
margin-bottom: 2rem;
}
.header-badges {
display: flex;
justify-content: center;
gap: 15px;
font-family: "Cinzel", serif;
font-size: 0.7rem;
margin-bottom: 0.5rem;
color: #b8860b;
}
h1 {
font-family: "Cinzel", serif;
font-size: 2rem;
color: #3a352a;
margin: 0.5rem 0;
}
.titre-honorifique {
font-style: italic;
opacity: 0.8;
font-size: 0.95rem;
margin-bottom: 0.5rem;
}
.meta {
font-family: "Cinzel", serif;
font-size: 0.8rem;
color: #b8860b;
letter-spacing: 1px;
}
/* LAYOUT MOBILE */
.visual-and-stats {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
#character-canvas-container {
width: 100%;
aspect-ratio: 1;
background: #fcfaf7;
border: 1px solid #eee5d8;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #dcd0b9;
font-family: "Cinzel", serif;
font-size: 0.8rem;
}
.physique {
font-size: 0.85rem;
font-style: italic;
color: #7a7362;
margin-top: 0.5rem;
text-align: center;
}
/* STATS EN TAGS (Ultra lisible) */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.stat-tag {
background: #f9f7f2;
border: 1px solid #eee5d8;
padding: 6px;
text-align: center;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.stat-tag.proficient {
border-color: #b8860b;
background: #fffdf5;
}
.s-label {
font-size: 0.6rem;
font-family: "Cinzel", serif;
color: #b8860b;
}
.s-mod {
font-size: 1.1rem;
font-weight: bold;
}
.s-val {
font-size: 0.65rem;
opacity: 0.5;
}
/* TECHNIQUES & TAGS */
.technical-info section {
margin-bottom: 1.5rem;
}
h3 {
font-family: "Cinzel", serif;
font-size: 0.85rem;
border-bottom: 1px solid #f0eada;
padding-bottom: 4px;
margin-bottom: 0.8rem;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 0.75rem;
padding: 3px 8px;
border: 1px solid #eee5d8;
background: #fff;
border-radius: 3px;
}
.tag.alt {
border-left: 3px solid #b8860b;
}
.tag.spell {
border-left: 3px solid #0070ff;
}
.separator {
border: 0;
height: 1px;
background-image: linear-gradient(
to right,
transparent,
#dcd0b9,
transparent
);
margin: 2rem 0;
}
.story-divider {
text-align: center;
margin: 3rem 0 2rem;
border-bottom: 1px solid #dcd0b9;
height: 10px;
}
.story-divider span {
background: white;
padding: 0 15px;
font-family: "Cinzel", serif;
font-size: 0.75rem;
color: #b8860b;
text-transform: uppercase;
}
.prose-content {
font-size: 1.1rem;
line-height: 1.8;
color: #4a453c;
}
/* MEDIA QUERIES (Desktop) */
@media (min-width: 768px) {
.character-sheet {
padding: 3rem;
margin: 2rem auto;
}
h1 {
font-size: 2.8rem;
}
.visual-and-stats {
flex-direction: row;
align-items: flex-start;
}
.canvas-area {
flex: 1;
}
.stats-grid {
flex: 1;
grid-template-columns: repeat(2, 1fr);
}
.technical-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
}
/* MEDIA QUERIES (Pure Mobile - On retire les bordures comme dans ton journal) */
@media (max-width: 640px) {
.character-sheet {
padding: 1.5rem 1rem;
border: none;
background: transparent;
box-shadow: none;
}
.story-divider span {
background: #fcfaf7; /* Ajuste selon ton fond de page GameLayout */
}
}
</style>