Ergänze 3D-Vorschau zur individuellen Haar- und Augenfarbe
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s
All checks were successful
Deploy to production / deploy (push) Successful in 2m36s
This commit is contained in:
157
frontend/src/components/falukant/CharacterAppearanceEditor.vue
Normal file
157
frontend/src/components/falukant/CharacterAppearanceEditor.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<section class="appearance-editor">
|
||||
<div class="appearance-editor__controls">
|
||||
<h2>Individuelles Aussehen</h2>
|
||||
<p>Modell auswählen oder eine GLB-Datei öffnen. Haare und Iris einmal markieren, danach die Farben frei ändern.</p>
|
||||
<div class="appearance-editor__pair">
|
||||
<label>Modell
|
||||
<select v-model="gender" @change="useBuiltInModel">
|
||||
<option value="female">Weiblich</option><option value="male">Männlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Alter
|
||||
<select v-model.number="age" @change="useBuiltInModel">
|
||||
<option v-for="value in ages" :key="value" :value="value">{{ value }} Jahre</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="appearance-editor__file">GLB-Datei öffnen
|
||||
<input type="file" accept=".glb,model/gltf-binary" @change="openFile" />
|
||||
</label>
|
||||
<p v-if="fileName" class="appearance-editor__note">{{ fileName }}</p>
|
||||
<fieldset>
|
||||
<legend>Bereiche zuordnen</legend>
|
||||
<div class="appearance-editor__tools">
|
||||
<button v-for="tool in tools" :key="tool.value" type="button"
|
||||
:aria-pressed="mode === tool.value" :class="{ active: mode === tool.value }"
|
||||
@click="mode = tool.value">{{ tool.label }}</button>
|
||||
</div>
|
||||
<p class="appearance-editor__note">{{ toolHint }}</p>
|
||||
<label v-if="mode !== 'rotate'">Pinselgröße
|
||||
<input v-model.number="brushSize" type="range" min="0.002" max="0.06" step="0.001" />
|
||||
</label>
|
||||
<button type="button" :disabled="!loaded" @click="$refs.character?.undoAppearance()">Markierung rückgängig</button>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Farben ausprobieren</legend>
|
||||
<div class="appearance-editor__pair">
|
||||
<label>Haarfarbe <input v-model="hairColor" type="color" /></label>
|
||||
<label>Augenfarbe <input v-model="eyeColor" type="color" /></label>
|
||||
</div>
|
||||
<label class="appearance-editor__checkbox"><input v-model="showColors" type="checkbox" /> Farbänderungen anzeigen</label>
|
||||
</fieldset>
|
||||
<p class="appearance-editor__note">Haare: {{ stats.hair ? 'markiert' : 'noch nicht markiert' }} · Iris: {{ stats.iris ? 'markiert' : 'noch nicht markiert' }}</p>
|
||||
<button type="button" :disabled="!loaded || exporting" @click="downloadModel">
|
||||
{{ exporting ? 'GLB wird erstellt …' : 'Bearbeitetes GLB herunterladen' }}
|
||||
</button>
|
||||
<p class="appearance-editor__note">Gespeichert werden die markierten Bereiche. Die Vorschaufarben lassen sich später pro Charakter einstellen.</p>
|
||||
<p v-if="error" role="alert" class="appearance-editor__error">{{ error }}</p>
|
||||
<p v-else role="status" class="appearance-editor__note">{{ status }}</p>
|
||||
</div>
|
||||
<div class="appearance-editor__stage" :class="{ 'is-painting': mode !== 'rotate' }">
|
||||
<Character3D :key="modelKey" ref="character" :gender="gender" :age="age"
|
||||
:model-src="localSource" :appearance="appearance" editable :paint-region="paintRegion"
|
||||
:brush-size="brushSize" no-background
|
||||
@model-loaded="modelLoaded" @appearance-changed="stats = $event"
|
||||
@model-error="modelError" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Character3D from '@/components/Character3D.vue';
|
||||
|
||||
export default {
|
||||
name: 'CharacterAppearanceEditor',
|
||||
components: { Character3D },
|
||||
data() {
|
||||
return {
|
||||
gender: 'female', age: 20,
|
||||
ages: [...Array(17).keys(), 18, 20, 25, 30, 40, 50, 60],
|
||||
localSource: null, fileName: '', mode: 'rotate', brushSize: 0.015,
|
||||
hairColor: '#69432d', eyeColor: '#3d92b5', showColors: true,
|
||||
loaded: false, exporting: false, status: 'Modell wird geladen …', error: '',
|
||||
stats: { body: 0, hair: 0, iris: 0 },
|
||||
tools: [
|
||||
{ value: 'rotate', label: 'Drehen / Zoom' },
|
||||
{ value: 'hair', label: 'Haare markieren' },
|
||||
{ value: 'iris', label: 'Iris markieren' },
|
||||
{ value: 'body', label: 'Markierung entfernen' },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
appearance() { return { hairColor: this.showColors ? this.hairColor : null, eyeColor: this.showColors ? this.eyeColor : null }; },
|
||||
modelKey() { return this.localSource || `${this.gender}-${this.age}`; },
|
||||
paintRegion() { return this.mode === 'rotate' ? null : this.mode; },
|
||||
toolHint() {
|
||||
if (this.mode === 'rotate') return 'Ziehen zum Drehen, Mausrad zum Zoomen, rechte Maustaste zum Verschieben.';
|
||||
if (this.mode === 'iris') return 'Nah heranzoomen und nur die farbige Iris markieren. Pupille und Augenweiß aussparen.';
|
||||
if (this.mode === 'body') return 'Über eine Fläche streichen, um dort die ursprüngliche Textur wiederherzustellen.';
|
||||
return 'Über die sichtbaren Haarflächen streichen. Für Rückseite und Seiten wieder auf Drehen wechseln.';
|
||||
},
|
||||
},
|
||||
beforeUnmount() { if (this.localSource) URL.revokeObjectURL(this.localSource); },
|
||||
methods: {
|
||||
resetStatus() {
|
||||
this.loaded = false;
|
||||
this.error = '';
|
||||
this.status = 'Modell wird geladen …';
|
||||
this.stats = { body: 0, hair: 0, iris: 0 };
|
||||
},
|
||||
useBuiltInModel() {
|
||||
if (this.localSource) URL.revokeObjectURL(this.localSource);
|
||||
this.localSource = null;
|
||||
this.fileName = '';
|
||||
this.resetStatus();
|
||||
},
|
||||
openFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!/\.glb$/i.test(file.name)) { this.error = 'Bitte eine GLB-Datei auswählen.'; return; }
|
||||
if (this.localSource) URL.revokeObjectURL(this.localSource);
|
||||
this.localSource = URL.createObjectURL(file);
|
||||
this.fileName = file.name;
|
||||
this.resetStatus();
|
||||
event.target.value = '';
|
||||
},
|
||||
modelLoaded(stats) { this.loaded = true; this.stats = stats; this.status = 'Modell bereit. Farben wirken auf markierte Bereiche.'; },
|
||||
modelError(message) { this.loaded = false; this.error = message; this.status = ''; },
|
||||
async downloadModel() {
|
||||
this.exporting = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const blob = await this.$refs.character.exportAppearanceModel();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = (this.fileName || `${this.gender}_${this.age}y.glb`).replace(/_opt\.glb$/i, '.glb');
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
this.status = 'GLB exportiert. Zum Prüfen die heruntergeladene Datei erneut öffnen.';
|
||||
} catch (error) { this.error = error.message || 'Export fehlgeschlagen.'; }
|
||||
finally { this.exporting = false; }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.appearance-editor { display: grid; grid-template-columns: minmax(250px, 340px) minmax(0, 1fr); gap: 24px; }
|
||||
.appearance-editor__controls { display: flex; flex-direction: column; align-items: stretch; gap: 14px; }
|
||||
.appearance-editor__controls h2, .appearance-editor__controls p { margin: 0; }
|
||||
.appearance-editor__controls label { display: flex; flex-direction: column; gap: 6px; }
|
||||
.appearance-editor__pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.appearance-editor__pair input[type=color] { width: 100%; min-height: 42px; padding: 3px; }
|
||||
.appearance-editor__controls fieldset { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--color-border, #ccc); border-radius: 8px; }
|
||||
.appearance-editor__tools { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.appearance-editor__tools button.active { outline: 2px solid var(--color-primary, #39844f); outline-offset: 1px; }
|
||||
.appearance-editor__controls .appearance-editor__checkbox { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.appearance-editor__note { font-size: 0.875rem; line-height: 1.5; color: var(--color-text-secondary, #5a655d); overflow-wrap: anywhere; }
|
||||
.appearance-editor__error { color: var(--color-danger, #a63030); }
|
||||
.appearance-editor__stage { height: 720px; min-height: 500px; border-radius: 12px; border: 1px solid var(--color-border, #ccc); background: #e6e4de; overflow: hidden; position: sticky; top: 12px; }
|
||||
.appearance-editor__stage :deep(.character-3d-shell), .appearance-editor__stage :deep(.character-3d-container) { width: 100%; height: 100%; }
|
||||
.appearance-editor__stage.is-painting :deep(canvas) { cursor: crosshair; }
|
||||
.appearance-editor__stage :deep(.character-debug-overlay) { display: none; }
|
||||
@media (max-width: 760px) { .appearance-editor { grid-template-columns: 1fr; } .appearance-editor__stage { grid-row: 1; position: static; height: 480px; min-height: 0; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user