Compare commits
5 Commits
main
...
falukant-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74f7b852b | ||
|
|
92d6b15c3f | ||
|
|
91f59062f5 | ||
|
|
1674086c73 | ||
|
|
5ddb099f5a |
@@ -2462,12 +2462,82 @@ class FalukantService extends BaseService {
|
|||||||
try {
|
try {
|
||||||
const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
|
const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
|
||||||
const proposalCount = Math.floor(Math.random() * 3) + 3;
|
const proposalCount = Math.floor(Math.random() * 3) + 3;
|
||||||
for (let i = 0; i < proposalCount; i++) {
|
|
||||||
const directorCharacter = await FalukantCharacter.findOne({
|
// Hole bereits existierende Proposals, um diese Charaktere auszuschließen
|
||||||
where: {
|
const existingProposals = await DirectorProposal.findAll({
|
||||||
regionId,
|
where: { employerUserId: falukantUserId },
|
||||||
createdAt: { [Op.lt]: threeWeeksAgo },
|
attributes: ['directorCharacterId'],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
const proposalCharacterIds = existingProposals.map(p => p.directorCharacterId);
|
||||||
|
|
||||||
|
// Hole alle Charaktere, die bereits als Direktor arbeiten (egal für welchen User)
|
||||||
|
const existingDirectors = await Director.findAll({
|
||||||
|
attributes: ['directorCharacterId'],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
const directorCharacterIds = existingDirectors.map(d => d.directorCharacterId);
|
||||||
|
|
||||||
|
// Kombiniere beide Listen
|
||||||
|
const excludedCharacterIds = [...new Set([...proposalCharacterIds, ...directorCharacterIds])];
|
||||||
|
|
||||||
|
console.log(`[generateProposals] Excluding ${excludedCharacterIds.length} characters (${proposalCharacterIds.length} proposals + ${directorCharacterIds.length} active directors)`);
|
||||||
|
console.log(`[generateProposals] Region ID: ${regionId}, Proposal count needed: ${proposalCount}`);
|
||||||
|
|
||||||
|
// Versuche zuerst Charaktere, die mindestens 3 Wochen alt sind
|
||||||
|
let whereClause = {
|
||||||
|
regionId,
|
||||||
|
userId: null, // Nur NPCs
|
||||||
|
};
|
||||||
|
|
||||||
|
if (excludedCharacterIds.length > 0) {
|
||||||
|
whereClause.id = { [Op.notIn]: excludedCharacterIds };
|
||||||
|
}
|
||||||
|
whereClause.createdAt = { [Op.lt]: threeWeeksAgo };
|
||||||
|
|
||||||
|
// Erstelle Query-Objekt für Logging
|
||||||
|
const queryOptions = {
|
||||||
|
where: whereClause,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: TitleOfNobility,
|
||||||
|
as: 'nobleTitle',
|
||||||
|
attributes: ['level'],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
order: sequelize.literal('RANDOM()'),
|
||||||
|
limit: proposalCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logge die SQL-Query
|
||||||
|
try {
|
||||||
|
const query = FalukantCharacter.findAll(queryOptions);
|
||||||
|
const sqlQuery = query.toSQL ? query.toSQL() : query;
|
||||||
|
console.log(`[generateProposals] SQL Query (older than 3 weeks):`, JSON.stringify(sqlQuery, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: Logge die Query-Optionen direkt
|
||||||
|
console.log(`[generateProposals] Query Options (older than 3 weeks):`, JSON.stringify(queryOptions, null, 2));
|
||||||
|
}
|
||||||
|
console.log(`[generateProposals] WHERE clause:`, JSON.stringify(whereClause, null, 2));
|
||||||
|
console.log(`[generateProposals] Excluded character IDs:`, excludedCharacterIds);
|
||||||
|
|
||||||
|
let directorCharacters = await FalukantCharacter.findAll(queryOptions);
|
||||||
|
|
||||||
|
// Fallback: Wenn nicht genug ältere Charaktere gefunden werden, verwende auch neuere
|
||||||
|
if (directorCharacters.length < proposalCount) {
|
||||||
|
console.log(`[generateProposals] Only found ${directorCharacters.length} characters older than 3 weeks, trying all NPCs...`);
|
||||||
|
|
||||||
|
const fallbackWhereClause = {
|
||||||
|
regionId,
|
||||||
|
userId: null, // Nur NPCs
|
||||||
|
};
|
||||||
|
|
||||||
|
if (excludedCharacterIds.length > 0) {
|
||||||
|
fallbackWhereClause.id = { [Op.notIn]: excludedCharacterIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackQueryOptions = {
|
||||||
|
where: fallbackWhereClause,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: TitleOfNobility,
|
model: TitleOfNobility,
|
||||||
@@ -2476,22 +2546,81 @@ class FalukantService extends BaseService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: sequelize.literal('RANDOM()'),
|
order: sequelize.literal('RANDOM()'),
|
||||||
});
|
limit: proposalCount,
|
||||||
if (!directorCharacter) {
|
};
|
||||||
throw new Error('No directors available for the region');
|
|
||||||
|
// Logge die Fallback-SQL-Query
|
||||||
|
try {
|
||||||
|
const fallbackQuery = FalukantCharacter.findAll(fallbackQueryOptions);
|
||||||
|
const fallbackSqlQuery = fallbackQuery.toSQL ? fallbackQuery.toSQL() : fallbackQuery;
|
||||||
|
console.log(`[generateProposals] SQL Query (all NPCs):`, JSON.stringify(fallbackSqlQuery, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[generateProposals] Fallback Query Options:`, JSON.stringify(fallbackQueryOptions, null, 2));
|
||||||
}
|
}
|
||||||
const avgKnowledge = await this.calculateAverageKnowledge(directorCharacter.id);
|
console.log(`[generateProposals] Fallback WHERE clause:`, JSON.stringify(fallbackWhereClause, null, 2));
|
||||||
|
|
||||||
|
const fallbackCharacters = await FalukantCharacter.findAll(fallbackQueryOptions);
|
||||||
|
|
||||||
|
// Kombiniere beide Listen und entferne Duplikate
|
||||||
|
const allCharacterIds = new Set(directorCharacters.map(c => c.id));
|
||||||
|
fallbackCharacters.forEach(c => {
|
||||||
|
if (!allCharacterIds.has(c.id)) {
|
||||||
|
directorCharacters.push(c);
|
||||||
|
allCharacterIds.add(c.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limitiere auf proposalCount
|
||||||
|
directorCharacters = directorCharacters.slice(0, proposalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directorCharacters.length === 0) {
|
||||||
|
console.error(`[generateProposals] No NPCs found in region ${regionId} at all`);
|
||||||
|
throw new Error('No directors available for the region');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[generateProposals] Found ${directorCharacters.length} available NPCs`);
|
||||||
|
|
||||||
|
// Batch-Berechnung der Knowledge-Werte
|
||||||
|
const characterIds = directorCharacters.map(c => c.id);
|
||||||
|
const allKnowledges = await Knowledge.findAll({
|
||||||
|
where: { characterId: { [Op.in]: characterIds } },
|
||||||
|
attributes: ['characterId', 'knowledge'],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gruppiere Knowledge nach characterId und berechne Durchschnitt
|
||||||
|
const knowledgeMap = new Map();
|
||||||
|
characterIds.forEach(id => knowledgeMap.set(id, []));
|
||||||
|
allKnowledges.forEach(k => {
|
||||||
|
const list = knowledgeMap.get(k.characterId) || [];
|
||||||
|
list.push(k.knowledge);
|
||||||
|
knowledgeMap.set(k.characterId, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erstelle alle Proposals in einem Batch
|
||||||
|
const proposalsToCreate = directorCharacters.map(character => {
|
||||||
|
const knowledges = knowledgeMap.get(character.id) || [];
|
||||||
|
const avgKnowledge = knowledges.length > 0
|
||||||
|
? knowledges.reduce((sum, k) => sum + k, 0) / knowledges.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
const proposedIncome = Math.round(
|
const proposedIncome = Math.round(
|
||||||
directorCharacter.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
|
character.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
|
||||||
);
|
);
|
||||||
await DirectorProposal.create({
|
|
||||||
directorCharacterId: directorCharacter.id,
|
return {
|
||||||
|
directorCharacterId: character.id,
|
||||||
employerUserId: falukantUserId,
|
employerUserId: falukantUserId,
|
||||||
proposedIncome,
|
proposedIncome,
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
|
await DirectorProposal.bulkCreate(proposalsToCreate);
|
||||||
|
|
||||||
|
console.log(`[generateProposals] Created ${proposalsToCreate.length} director proposals for region ${regionId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error.message, error.stack);
|
console.error('[generateProposals] Error:', error.message, error.stack);
|
||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3175,29 +3304,54 @@ class FalukantService extends BaseService {
|
|||||||
}
|
}
|
||||||
const minTitle = minTitleResult.id;
|
const minTitle = minTitleResult.id;
|
||||||
|
|
||||||
|
// Logging für Debugging
|
||||||
|
console.log(`[createPossiblePartners] Searching for partners:`, {
|
||||||
|
requestingCharacterId,
|
||||||
|
requestingCharacterGender,
|
||||||
|
requestingRegionId,
|
||||||
|
requestingCharacterTitleOfNobility,
|
||||||
|
ownAge
|
||||||
|
});
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
id: { [Op.ne]: requestingCharacterId },
|
||||||
|
gender: { [Op.ne]: requestingCharacterGender },
|
||||||
|
regionId: requestingRegionId,
|
||||||
|
createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) },
|
||||||
|
titleOfNobility: { [Op.between]: [Math.max(1, requestingCharacterTitleOfNobility - 1), requestingCharacterTitleOfNobility + 1] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nur NPCs suchen (userId ist null)
|
||||||
|
whereClause.userId = null;
|
||||||
|
|
||||||
|
console.log(`[createPossiblePartners] Where clause:`, JSON.stringify(whereClause, null, 2));
|
||||||
|
|
||||||
const potentialPartners = await FalukantCharacter.findAll({
|
const potentialPartners = await FalukantCharacter.findAll({
|
||||||
where: {
|
where: whereClause,
|
||||||
id: { [Op.ne]: requestingCharacterId },
|
|
||||||
gender: { [Op.ne]: requestingCharacterGender },
|
|
||||||
regionId: requestingRegionId,
|
|
||||||
createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) },
|
|
||||||
titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] }
|
|
||||||
},
|
|
||||||
order: [
|
order: [
|
||||||
[Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC']
|
[Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC']
|
||||||
],
|
],
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners`);
|
||||||
|
|
||||||
|
if (potentialPartners.length === 0) {
|
||||||
|
console.warn(`[createPossiblePartners] No partners found with criteria. Consider creating NPCs.`);
|
||||||
|
return; // Keine Partner gefunden, aber kein Fehler
|
||||||
|
}
|
||||||
|
|
||||||
const proposals = potentialPartners.map(partner => {
|
const proposals = potentialPartners.map(partner => {
|
||||||
const age = calcAge(partner.birthdate);
|
const age = calcAge(partner.birthdate);
|
||||||
return {
|
return {
|
||||||
requesterCharacterId: requestingCharacterId,
|
requesterCharacterId: requestingCharacterId,
|
||||||
proposedCharacterId: partner.id,
|
proposedCharacterId: partner.id,
|
||||||
cost: calculateMarriageCost(partner.titleOfNobility, age, minTitle),
|
cost: calculateMarriageCost(partner.titleOfNobility, age),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await MarriageProposal.bulkCreate(proposals);
|
await MarriageProposal.bulkCreate(proposals);
|
||||||
|
console.log(`[createPossiblePartners] Created ${proposals.length} marriage proposals`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating possible partners:', error);
|
console.error('Error creating possible partners:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -3250,7 +3404,7 @@ class FalukantService extends BaseService {
|
|||||||
const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||||
if (!myChar) throw new Error('Character not found');
|
if (!myChar) throw new Error('Character not found');
|
||||||
|
|
||||||
// 2) Beziehung finden und „anderen“ Character bestimmen
|
// 2) Beziehung finden und „anderen" Character bestimmen
|
||||||
const rel = await Relationship.findOne({
|
const rel = await Relationship.findOne({
|
||||||
where: {
|
where: {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@@ -3263,32 +3417,44 @@ class FalukantService extends BaseService {
|
|||||||
{ model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] }
|
{ model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
if (!rel) throw new Error('Beziehung nicht gefunden');
|
|
||||||
|
|
||||||
const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1;
|
// 3) Wenn keine Beziehung gefunden, alle Gifts ohne Filter zurückgeben
|
||||||
|
let relatedTraitIds = [];
|
||||||
|
let relatedMoodId = null;
|
||||||
|
|
||||||
// 3) Trait-IDs und Mood des relatedChar
|
if (rel) {
|
||||||
const relatedTraitIds = relatedChar.traits.map(t => t.id);
|
const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1;
|
||||||
const relatedMoodId = relatedChar.moodId;
|
// Trait-IDs und Mood des relatedChar
|
||||||
|
relatedTraitIds = relatedChar.traits ? relatedChar.traits.map(t => t.id) : [];
|
||||||
|
relatedMoodId = relatedChar.moodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Gifts laden – mit Mood/Trait-Filter nur wenn Beziehung existiert
|
||||||
|
const giftIncludes = [
|
||||||
|
{
|
||||||
|
model: PromotionalGiftMood,
|
||||||
|
as: 'promotionalgiftmoods',
|
||||||
|
attributes: ['mood_id', 'suitability'],
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PromotionalGiftCharacterTrait,
|
||||||
|
as: 'characterTraits',
|
||||||
|
attributes: ['trait_id', 'suitability'],
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wenn Beziehung existiert, Filter anwenden
|
||||||
|
if (rel && relatedMoodId) {
|
||||||
|
giftIncludes[0].where = { mood_id: relatedMoodId };
|
||||||
|
}
|
||||||
|
if (rel && relatedTraitIds.length > 0) {
|
||||||
|
giftIncludes[1].where = { trait_id: relatedTraitIds };
|
||||||
|
}
|
||||||
|
|
||||||
// 4) Gifts laden – aber nur die passenden Moods und Traits als Unter-Arrays
|
|
||||||
const gifts = await PromotionalGift.findAll({
|
const gifts = await PromotionalGift.findAll({
|
||||||
include: [
|
include: giftIncludes
|
||||||
{
|
|
||||||
model: PromotionalGiftMood,
|
|
||||||
as: 'promotionalgiftmoods',
|
|
||||||
attributes: ['mood_id', 'suitability'],
|
|
||||||
where: { mood_id: relatedMoodId },
|
|
||||||
required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: PromotionalGiftCharacterTrait,
|
|
||||||
as: 'characterTraits',
|
|
||||||
attributes: ['trait_id', 'suitability'],
|
|
||||||
where: { trait_id: relatedTraitIds },
|
|
||||||
required: false // Gifts ohne Trait-Match bleiben erhalten
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5) Rest wie gehabt: Kosten berechnen und zurückgeben
|
// 5) Rest wie gehabt: Kosten berechnen und zurückgeben
|
||||||
|
|||||||
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal file
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# 3D-Animationen im Falukant-Bereich
|
||||||
|
|
||||||
|
## Benötigte Dependencies
|
||||||
|
|
||||||
|
### Three.js (Empfohlen)
|
||||||
|
```bash
|
||||||
|
npm install three
|
||||||
|
npm install @types/three --save-dev # Für TypeScript-Support
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative Optionen:**
|
||||||
|
- **Babylon.js**: Mächtiger, aber größer (~500KB vs ~600KB)
|
||||||
|
- **A-Frame**: WebVR-fokussiert, einfacher für VR/AR
|
||||||
|
- **React Three Fiber**: Falls React verwendet wird (hier Vue)
|
||||||
|
|
||||||
|
**Empfehlung: Three.js** - am weitesten verbreitet, beste Dokumentation, große Community
|
||||||
|
|
||||||
|
### Optional: Vue-Three.js Wrapper
|
||||||
|
```bash
|
||||||
|
npm install vue-threejs # Oder troika-three-text für Text-Rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sinnvolle Seiten für 3D-Animationen
|
||||||
|
|
||||||
|
### 1. **OverviewView** (Hauptübersicht)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
||||||
|
- **3D-Charakter-Modell**: Rotierendes 3D-Modell des eigenen Charakters
|
||||||
|
- **Statussymbole**: 3D-Icons für Geld, Gesundheit, Reputation (schwebend/rotierend)
|
||||||
|
- **Hintergrund**: Subtile 3D-Szene (z.B. mittelalterliche Stadt im Hintergrund)
|
||||||
|
|
||||||
|
### 2. **HouseView** (Haus)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
||||||
|
- **3D-Haus-Modell**: Interaktives 3D-Modell des eigenen Hauses
|
||||||
|
- **Upgrade-Visualisierung**: Animation beim Haus-Upgrade
|
||||||
|
- **Zustand-Anzeige**: 3D-Visualisierung von Dach, Wänden, Boden, Fenstern
|
||||||
|
|
||||||
|
### 3. **BranchView** (Niederlassungen)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Fabrik/Gebäude**: 3D-Modell der Niederlassung
|
||||||
|
- **Produktions-Animation**: 3D-Animationen für laufende Produktionen
|
||||||
|
- **Transport-Visualisierung**: 3D-Wagen/Karren für Transporte
|
||||||
|
|
||||||
|
### 4. **FamilyView** (Familie)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Charaktere**: 3D-Modelle von Partner und Kindern
|
||||||
|
- **Beziehungs-Visualisierung**: 3D-Animationen für Beziehungsstatus
|
||||||
|
- **Geschenk-Animation**: 3D-Animation beim Verschenken
|
||||||
|
|
||||||
|
### 5. **HealthView** (Gesundheit)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Körper-Modell**: 3D-Visualisierung des Gesundheitszustands
|
||||||
|
- **Aktivitäts-Animationen**: 3D-Animationen für Gesundheitsaktivitäten
|
||||||
|
|
||||||
|
### 6. **NobilityView** (Sozialstatus)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Wappen**: Rotierendes 3D-Wappen
|
||||||
|
- **Insignien**: 3D-Krone, Schwert, etc. je nach Titel
|
||||||
|
|
||||||
|
### 7. **ChurchView** (Kirche)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Kirche**: 3D-Modell der Kirche
|
||||||
|
- **Taufe-Animation**: 3D-Animation bei der Taufe
|
||||||
|
|
||||||
|
### 8. **BankView** (Bank)
|
||||||
|
**Sinnvoll:** ⭐⭐
|
||||||
|
- **3D-Bankgebäude**: 3D-Modell der Bank
|
||||||
|
- **Geld-Animation**: 3D-Münzen/Geldstapel
|
||||||
|
|
||||||
|
### 9. **UndergroundView** (Untergrund)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Dungeon**: 3D-Untergrund-Visualisierung
|
||||||
|
- **Aktivitäts-Animationen**: 3D-Animationen für Untergrund-Aktivitäten
|
||||||
|
|
||||||
|
### 10. **ReputationView** (Reputation)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Party-Szene**: 3D-Visualisierung von Festen
|
||||||
|
- **Reputation-Visualisierung**: 3D-Effekte für Reputationsänderungen
|
||||||
|
|
||||||
|
## Implementierungs-Strategie
|
||||||
|
|
||||||
|
### Phase 1: Basis-Setup
|
||||||
|
1. Three.js installieren
|
||||||
|
2. Basis-Komponente `ThreeScene.vue` erstellen
|
||||||
|
3. Erste einfache Animation (z.B. rotierender Würfel) auf OverviewView
|
||||||
|
|
||||||
|
### Phase 2: Charakter-Modell
|
||||||
|
1. 3D-Charakter-Modell erstellen/laden (GLTF/GLB)
|
||||||
|
2. Auf OverviewView integrieren
|
||||||
|
3. Interaktionen (Klick, Hover)
|
||||||
|
|
||||||
|
### Phase 3: Gebäude-Modelle
|
||||||
|
1. Haus-Modell für HouseView
|
||||||
|
2. Fabrik-Modell für BranchView
|
||||||
|
3. Kirche-Modell für ChurchView
|
||||||
|
|
||||||
|
### Phase 4: Animationen
|
||||||
|
1. Upgrade-Animationen
|
||||||
|
2. Status-Änderungs-Animationen
|
||||||
|
3. Interaktive Elemente
|
||||||
|
|
||||||
|
## Technische Überlegungen
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Lazy Loading**: 3D-Szenen nur laden, wenn Seite aktiv ist
|
||||||
|
- **Level of Detail (LOD)**: Einfache Modelle für schwächere Geräte
|
||||||
|
- **WebGL-Detection**: Fallback auf 2D, wenn WebGL nicht unterstützt wird
|
||||||
|
|
||||||
|
### Asset-Management
|
||||||
|
- **GLTF/GLB**: Kompaktes Format für 3D-Modelle
|
||||||
|
- **Texturen**: Optimiert für Web (WebP, komprimiert)
|
||||||
|
- **CDN**: Assets über CDN laden für bessere Performance
|
||||||
|
|
||||||
|
### Browser-Kompatibilität
|
||||||
|
- **WebGL 1.0**: Mindestanforderung (95%+ Browser)
|
||||||
|
- **WebGL 2.0**: Optional für bessere Features
|
||||||
|
- **Fallback**: 2D-Versionen für ältere Browser
|
||||||
|
|
||||||
|
## Beispiel-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
components/
|
||||||
|
falukant/
|
||||||
|
ThreeScene.vue # Basis-3D-Szene-Komponente
|
||||||
|
CharacterModel.vue # 3D-Charakter-Komponente
|
||||||
|
BuildingModel.vue # 3D-Gebäude-Komponente
|
||||||
|
assets/
|
||||||
|
3d/
|
||||||
|
models/
|
||||||
|
character.glb
|
||||||
|
house.glb
|
||||||
|
factory.glb
|
||||||
|
textures/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Three.js installieren**
|
||||||
|
2. **Basis-Komponente erstellen**
|
||||||
|
3. **Erste Animation auf OverviewView testen**
|
||||||
|
4. **3D-Modelle erstellen/beschaffen** (Blender, Sketchfab, etc.)
|
||||||
|
5. **Schrittweise auf weitere Seiten ausweiten**
|
||||||
171
docs/3D_ASSETS_STRUCTURE.md
Normal file
171
docs/3D_ASSETS_STRUCTURE.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# 3D-Assets Struktur für Falukant
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/public/
|
||||||
|
models/
|
||||||
|
3d/
|
||||||
|
falukant/
|
||||||
|
characters/
|
||||||
|
male.glb # Basis-Modell männlich
|
||||||
|
female.glb # Basis-Modell weiblich
|
||||||
|
male_child.glb # Männlich, Kind (0-9 Jahre)
|
||||||
|
male_teen.glb # Männlich, Teenager (10-17 Jahre)
|
||||||
|
male_adult.glb # Männlich, Erwachsen (18-39 Jahre)
|
||||||
|
male_middle.glb # Männlich, Mittelalter (40-59 Jahre)
|
||||||
|
male_elder.glb # Männlich, Älter (60+ Jahre)
|
||||||
|
female_child.glb # Weiblich, Kind
|
||||||
|
female_teen.glb # Weiblich, Teenager
|
||||||
|
female_adult.glb # Weiblich, Erwachsen
|
||||||
|
female_middle.glb # Weiblich, Mittelalter
|
||||||
|
female_elder.glb # Weiblich, Älter
|
||||||
|
buildings/
|
||||||
|
house/
|
||||||
|
house_small.glb # Kleines Haus
|
||||||
|
house_medium.glb # Mittleres Haus
|
||||||
|
house_large.glb # Großes Haus
|
||||||
|
factory/
|
||||||
|
factory_basic.glb # Basis-Fabrik
|
||||||
|
factory_advanced.glb # Erweiterte Fabrik
|
||||||
|
church/
|
||||||
|
church.glb # Kirche
|
||||||
|
bank/
|
||||||
|
bank.glb # Bank
|
||||||
|
objects/
|
||||||
|
weapons/
|
||||||
|
sword.glb
|
||||||
|
shield.glb
|
||||||
|
items/
|
||||||
|
coin.glb
|
||||||
|
gift.glb
|
||||||
|
effects/
|
||||||
|
particles/
|
||||||
|
money.glb # Geld-Effekt
|
||||||
|
health.glb # Gesundheits-Effekt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Namenskonventionen
|
||||||
|
|
||||||
|
### Charaktere
|
||||||
|
- Format: `{gender}[_{ageRange}].glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `male.glb` - Basis-Modell männlich (Fallback)
|
||||||
|
- `female.glb` - Basis-Modell weiblich (Fallback)
|
||||||
|
- `male_adult.glb` - Männlich, Erwachsen
|
||||||
|
- `female_teen.glb` - Weiblich, Teenager
|
||||||
|
|
||||||
|
### Gebäude
|
||||||
|
- Format: `{buildingType}_{variant}.glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `house_small.glb`
|
||||||
|
- `factory_basic.glb`
|
||||||
|
- `church.glb`
|
||||||
|
|
||||||
|
### Objekte
|
||||||
|
- Format: `{category}/{item}.glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `weapons/sword.glb`
|
||||||
|
- `items/coin.glb`
|
||||||
|
|
||||||
|
## Altersbereiche
|
||||||
|
|
||||||
|
Die Altersbereiche werden automatisch bestimmt:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In CharacterModel3D.vue
|
||||||
|
getAgeRange(age) {
|
||||||
|
if (age < 10) return 'child';
|
||||||
|
if (age < 18) return 'teen';
|
||||||
|
if (age < 40) return 'adult';
|
||||||
|
if (age < 60) return 'middle';
|
||||||
|
return 'elder';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback-Verhalten:**
|
||||||
|
- Wenn kein spezifisches Modell für den Altersbereich existiert, wird das Basis-Modell (`male.glb` / `female.glb`) verwendet
|
||||||
|
- Dies ermöglicht schrittweise Erweiterung ohne Breaking Changes
|
||||||
|
|
||||||
|
## Dateigrößen-Empfehlungen
|
||||||
|
|
||||||
|
- **Charaktere**: 100KB - 500KB (komprimiert)
|
||||||
|
- **Gebäude**: 200KB - 1MB (komprimiert)
|
||||||
|
- **Objekte**: 10KB - 100KB (komprimiert)
|
||||||
|
|
||||||
|
## Optimierung
|
||||||
|
|
||||||
|
### Vor dem Hochladen:
|
||||||
|
1. **Blender** öffnen
|
||||||
|
2. **Decimate Modifier** anwenden (falls nötig)
|
||||||
|
3. **Texturen komprimieren** (WebP, max 1024x1024)
|
||||||
|
4. **GLB Export** mit:
|
||||||
|
- Compression aktiviert
|
||||||
|
- Texturen eingebettet
|
||||||
|
- Unnötige Animationen entfernt
|
||||||
|
|
||||||
|
### Komprimierung:
|
||||||
|
- Verwende `gltf-pipeline` oder `gltf-transform` für weitere Komprimierung
|
||||||
|
- Ziel: < 500KB pro Modell
|
||||||
|
|
||||||
|
## Verwendung im Code
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- CharacterModel3D.vue -->
|
||||||
|
<CharacterModel3D
|
||||||
|
:gender="character.gender"
|
||||||
|
:age="character.age"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Automatisch wird geladen: -->
|
||||||
|
<!-- /models/3d/falukant/characters/male_adult.glb -->
|
||||||
|
<!-- Falls nicht vorhanden: male.glb -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erweiterte Struktur (Optional)
|
||||||
|
|
||||||
|
Für komplexere Szenarien:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/public/
|
||||||
|
models/
|
||||||
|
3d/
|
||||||
|
falukant/
|
||||||
|
characters/
|
||||||
|
{gender}/
|
||||||
|
base/
|
||||||
|
{gender}.glb # Basis-Modell
|
||||||
|
ages/
|
||||||
|
{gender}_{ageRange}.glb
|
||||||
|
variants/
|
||||||
|
{gender}_{variant}.glb # Z.B. verschiedene Outfits
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wartung
|
||||||
|
|
||||||
|
### Neue Modelle hinzufügen:
|
||||||
|
1. GLB-Datei in entsprechendes Verzeichnis kopieren
|
||||||
|
2. Namenskonvention beachten
|
||||||
|
3. Dateigröße prüfen (< 500KB empfohlen)
|
||||||
|
4. Im Browser testen
|
||||||
|
|
||||||
|
### Modelle aktualisieren:
|
||||||
|
1. Alte Datei ersetzen
|
||||||
|
2. Browser-Cache leeren (oder Versionierung verwenden)
|
||||||
|
3. Testen
|
||||||
|
|
||||||
|
### Versionierung (Optional):
|
||||||
|
```
|
||||||
|
characters/
|
||||||
|
v1/
|
||||||
|
male.glb
|
||||||
|
v2/
|
||||||
|
male.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance-Tipps
|
||||||
|
|
||||||
|
1. **Lazy Loading**: Modelle nur laden, wenn benötigt
|
||||||
|
2. **Preloading**: Wichtige Modelle vorladen
|
||||||
|
3. **Caching**: Browser-Cache nutzen
|
||||||
|
4. **CDN**: Für Produktion CDN verwenden
|
||||||
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal file
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 3D-Modell-Erstellung für Falukant
|
||||||
|
|
||||||
|
## KI-basierte Tools (Empfohlen)
|
||||||
|
|
||||||
|
### 1. **Rodin** ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://rodin.io/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D (z.B. "medieval character", "house")
|
||||||
|
- Sehr gute Qualität
|
||||||
|
- Export als GLB/GLTF
|
||||||
|
- **Gut für**: Charaktere, Gebäude, Objekte
|
||||||
|
|
||||||
|
### 2. **Meshy** ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.meshy.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), ab $9/monat
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Textur-Generierung
|
||||||
|
- Export als GLB/OBJ/FBX
|
||||||
|
- **Gut für**: Alle Arten von Modellen
|
||||||
|
|
||||||
|
### 3. **Luma AI Genie** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://lumalabs.ai/genie
|
||||||
|
- **Preis**: Kostenlos (Beta)
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Sehr schnell
|
||||||
|
- Export als GLB
|
||||||
|
- **Gut für**: Schnelle Prototypen
|
||||||
|
|
||||||
|
### 4. **CSM (Common Sense Machines)** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://csm.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits)
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Export als GLB/USD
|
||||||
|
- **Gut für**: Verschiedene Objekte
|
||||||
|
|
||||||
|
### 5. **Tripo AI** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.tripo3d.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Export als GLB/FBX/OBJ
|
||||||
|
- **Gut für**: Charaktere und Objekte
|
||||||
|
|
||||||
|
### 6. **Masterpiece Studio** ⭐⭐⭐
|
||||||
|
- **URL**: https://masterpiecestudio.com/
|
||||||
|
- **Preis**: Ab $9/monat
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- VR-Unterstützung
|
||||||
|
- Export als GLB/FBX
|
||||||
|
- **Gut für**: Professionelle Modelle
|
||||||
|
|
||||||
|
## Traditionelle Tools (Für Nachbearbeitung)
|
||||||
|
|
||||||
|
### 1. **Blender** (Kostenlos) ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.blender.org/
|
||||||
|
- **Features**:
|
||||||
|
- Vollständige 3D-Suite
|
||||||
|
- GLB/GLTF Export
|
||||||
|
- Optimierung von KI-generierten Modellen
|
||||||
|
- **Gut für**: Nachbearbeitung, Optimierung, Animationen
|
||||||
|
|
||||||
|
### 2. **Sketchfab** (Modelle kaufen/laden)
|
||||||
|
- **URL**: https://sketchfab.com/
|
||||||
|
- **Preis**: Kostenlos (CC0 Modelle), Premium Modelle kostenpflichtig
|
||||||
|
- **Features**:
|
||||||
|
- Millionen von 3D-Modellen
|
||||||
|
- Viele kostenlose CC0 Modelle
|
||||||
|
- GLB/GLTF Download
|
||||||
|
- **Gut für**: Vorgefertigte Modelle, Inspiration
|
||||||
|
|
||||||
|
## Empfohlener Workflow
|
||||||
|
|
||||||
|
### Für Falukant-Charaktere:
|
||||||
|
1. **Rodin** oder **Meshy** verwenden
|
||||||
|
2. Prompt: "medieval character, male/female, simple style, low poly, game ready"
|
||||||
|
3. Export als GLB
|
||||||
|
4. In **Blender** optimieren (falls nötig)
|
||||||
|
5. Texturen anpassen
|
||||||
|
|
||||||
|
### Für Gebäude:
|
||||||
|
1. **Meshy** oder **Tripo AI** verwenden
|
||||||
|
2. Prompt: "medieval house, simple, low poly, game ready, front view"
|
||||||
|
3. Export als GLB
|
||||||
|
4. In **Blender** optimieren
|
||||||
|
5. Mehrere Varianten erstellen (Haus, Fabrik, Kirche)
|
||||||
|
|
||||||
|
### Für Objekte:
|
||||||
|
1. **Sketchfab** durchsuchen (kostenlose CC0 Modelle)
|
||||||
|
2. Oder **Meshy** für spezifische Objekte
|
||||||
|
3. Export als GLB
|
||||||
|
4. Optimieren falls nötig
|
||||||
|
|
||||||
|
## Prompt-Beispiele für Falukant
|
||||||
|
|
||||||
|
### Charakter:
|
||||||
|
```
|
||||||
|
"medieval character, [male/female], simple low poly style,
|
||||||
|
game ready, neutral pose, front view, no background,
|
||||||
|
GLB format, optimized for web"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Haus:
|
||||||
|
```
|
||||||
|
"medieval house, simple low poly style, game ready,
|
||||||
|
front view, no background, GLB format, optimized for web"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fabrik:
|
||||||
|
```
|
||||||
|
"medieval factory building, simple low poly style,
|
||||||
|
game ready, front view, no background, GLB format"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wappen:
|
||||||
|
```
|
||||||
|
"medieval coat of arms shield, simple low poly style,
|
||||||
|
game ready, front view, no background, GLB format"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimierung für Web
|
||||||
|
|
||||||
|
### Nach der Erstellung:
|
||||||
|
1. **Blender** öffnen
|
||||||
|
2. **Decimate Modifier** anwenden (weniger Polygone)
|
||||||
|
3. **Texture** komprimieren (WebP, 512x512 oder 1024x1024)
|
||||||
|
4. **GLB Export** mit:
|
||||||
|
- Compression aktiviert
|
||||||
|
- Texturen eingebettet
|
||||||
|
- Normals und Tangents berechnet
|
||||||
|
|
||||||
|
### Größen-Richtlinien:
|
||||||
|
- **Charaktere**: 2000-5000 Polygone
|
||||||
|
- **Gebäude**: 1000-3000 Polygone
|
||||||
|
- **Objekte**: 100-1000 Polygone
|
||||||
|
- **Texturen**: 512x512 oder 1024x1024 (nicht größer)
|
||||||
|
|
||||||
|
## Kostenlose Alternativen
|
||||||
|
|
||||||
|
### Wenn KI-Tools Limits haben:
|
||||||
|
1. **Sketchfab** durchsuchen (CC0 Modelle)
|
||||||
|
2. **Poly Haven** (https://polyhaven.com/) - kostenlose Assets
|
||||||
|
3. **Kenney.nl** - kostenlose Game Assets
|
||||||
|
4. **OpenGameArt.org** - kostenlose Game Assets
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Rodin** oder **Meshy** testen
|
||||||
|
2. Ersten Charakter erstellen
|
||||||
|
3. Als GLB exportieren
|
||||||
|
4. In Three.js testen
|
||||||
|
5. Bei Bedarf optimieren
|
||||||
334
docs/BLENDER_RIGGING_GUIDE.md
Normal file
334
docs/BLENDER_RIGGING_GUIDE.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Blender Rigging-Anleitung für Falukant-Charaktere
|
||||||
|
|
||||||
|
Diese Anleitung erklärt, wie du Bones/Gelenke zu deinen 3D-Modellen in Blender hinzufügst, damit sie animiert werden können.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Blender (kostenlos, https://www.blender.org/)
|
||||||
|
- GLB-Modell von meshy.ai oder anderen Quellen
|
||||||
|
|
||||||
|
## Schritt-für-Schritt Anleitung
|
||||||
|
|
||||||
|
### 1. Modell in Blender importieren
|
||||||
|
|
||||||
|
1. Öffne Blender
|
||||||
|
2. Gehe zu `File` → `Import` → `glTF 2.0 (.glb/.gltf)`
|
||||||
|
3. Wähle dein Modell aus
|
||||||
|
4. Das Modell sollte jetzt in der Szene erscheinen
|
||||||
|
|
||||||
|
### 2. Modell vorbereiten
|
||||||
|
|
||||||
|
1. Stelle sicher, dass das Modell im **Object Mode** ist (Tab drücken, falls im Edit Mode)
|
||||||
|
2. Wähle das Modell aus (Linksklick)
|
||||||
|
3. Drücke `Alt + G` um die Position auf (0, 0, 0) zu setzen
|
||||||
|
4. Drücke `Alt + R` um die Rotation zurückzusetzen
|
||||||
|
5. Drücke `Alt + S` um die Skalierung auf 1 zu setzen
|
||||||
|
|
||||||
|
### 3. Rigging (Bones hinzufügen)
|
||||||
|
|
||||||
|
#### Option A: Automatisches Rigging mit Rigify (Empfohlen)
|
||||||
|
|
||||||
|
1. **Rigify aktivieren:**
|
||||||
|
- Gehe zu `Edit` → `Preferences` (oder `Blender` → `Preferences` auf Mac)
|
||||||
|
- Klicke auf den Tab **"Add-ons"** (links im Fenster)
|
||||||
|
- Im Suchfeld oben rechts tippe: **"rigify"** (ohne Anführungszeichen)
|
||||||
|
- Du solltest "Rigify: Auto-rigging system" sehen
|
||||||
|
- Aktiviere das **Häkchen** neben "Rigify"
|
||||||
|
- Das Add-on ist jetzt aktiviert
|
||||||
|
- Schließe das Preferences-Fenster
|
||||||
|
|
||||||
|
**Alternative Wege zu Preferences:**
|
||||||
|
- Windows/Linux: `Edit` → `Preferences`
|
||||||
|
- Mac: `Blender` → `Preferences`
|
||||||
|
- Oder: `Ctrl + ,` (Strg + Komma)
|
||||||
|
|
||||||
|
2. **Rigify-Rig hinzufügen:**
|
||||||
|
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken, falls im Edit Mode)
|
||||||
|
- Wähle das Modell aus (oder nichts, das Rig wird separat erstellt)
|
||||||
|
- Drücke `Shift + A` (Add Menu)
|
||||||
|
- Wähle **`Armature`** aus
|
||||||
|
- In der Liste siehst du jetzt **`Human (Meta-Rig)`** - klicke darauf
|
||||||
|
- Ein Basis-Rig wird in der Szene erstellt
|
||||||
|
|
||||||
|
**Falls "Human (Meta-Rig)" nicht erscheint:**
|
||||||
|
- Stelle sicher, dass Rigify aktiviert ist (siehe Schritt 1)
|
||||||
|
- Starte Blender neu, falls nötig
|
||||||
|
- Prüfe, ob du die neueste Blender-Version hast (Rigify ist ab Version 2.8+ verfügbar)
|
||||||
|
|
||||||
|
3. **Rig positionieren und anpassen:**
|
||||||
|
|
||||||
|
**Schritt 1: Rig zum Modell bewegen**
|
||||||
|
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken)
|
||||||
|
- Wähle das **Armature** aus (nicht das Modell)
|
||||||
|
- Drücke `G` (Grab/Move) und bewege das Rig zum Modell
|
||||||
|
- Oder: Drücke `Alt + G` um die Position zurückzusetzen, dann `G` + `X`, `Y` oder `Z` für eine Achse
|
||||||
|
|
||||||
|
**Schritt 2: Rig skalieren (falls zu groß/klein)**
|
||||||
|
- Wähle das Armature aus
|
||||||
|
- Drücke `S` (Scale) und skaliere das Rig
|
||||||
|
- Oder: `S` + `X`, `Y` oder `Z` für eine Achse
|
||||||
|
- Tipp: Drücke `Shift + X` (oder Y/Z) um diese Achse auszuschließen
|
||||||
|
|
||||||
|
**Schritt 3: Einzelne Bones anpassen**
|
||||||
|
- Wähle das Armature aus
|
||||||
|
- Wechsle in den **Edit Mode** (Tab)
|
||||||
|
- Wähle einen Bone aus (Linksklick)
|
||||||
|
- Drücke `G` um ihn zu bewegen
|
||||||
|
- Drücke `E` um einen neuen Bone zu extrudieren
|
||||||
|
- Drücke `R` um einen Bone zu rotieren
|
||||||
|
- Drücke `S` um einen Bone zu skalieren
|
||||||
|
|
||||||
|
**Wichtige Bones zum Anpassen:**
|
||||||
|
- **Root/Spine** - Sollte in der Mitte des Körpers sein (Hüfthöhe)
|
||||||
|
- **Spine1/Spine2** - Entlang der Wirbelsäule
|
||||||
|
- **Neck/Head** - Am Hals und Kopf
|
||||||
|
- **Shoulders** - An den Schultern
|
||||||
|
- **Arms** - Entlang der Arme
|
||||||
|
- **Legs** - Entlang der Beine
|
||||||
|
|
||||||
|
**Tipp:** Nutze die Zahlenansicht (Numpad) um die Positionen genau zu sehen
|
||||||
|
|
||||||
|
4. **Rig generieren:**
|
||||||
|
- Wechsle zurück in den **Object Mode** (Tab drücken)
|
||||||
|
- Wähle das **Meta-Rig (Armature)** aus (nicht das Modell!) - sollte im Outliner blau markiert sein
|
||||||
|
|
||||||
|
**Methode 1: Rigify-Button in der Toolbar (Einfachste Methode)**
|
||||||
|
- Oben in der Toolbar siehst du den Button **"Rigify"** (neben "Object")
|
||||||
|
- Klicke auf **"Rigify"** → **"Generate Rig"**
|
||||||
|
- Ein vollständiges Rig wird erstellt (dies kann einen Moment dauern)
|
||||||
|
|
||||||
|
**Methode 2: Properties-Panel (Alternative)**
|
||||||
|
- Im **Properties-Panel** (rechts):
|
||||||
|
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
||||||
|
- Oder: Klicke auf das **Bone-Icon** (Armature Properties)
|
||||||
|
- Scrolle durch die Tabs, bis du **"Rigify"** oder **"Rigify Generation"** siehst
|
||||||
|
- In diesem Tab findest du den Button **"Generate Rig"**
|
||||||
|
- Klicke auf **"Generate Rig"**
|
||||||
|
|
||||||
|
**Wichtig:** Nach dem Generieren kannst du das Rig weiter anpassen, aber du musst es im **Pose Mode** tun (nicht Edit Mode)
|
||||||
|
|
||||||
|
**Die richtigen Tabs im Properties-Panel (von oben nach unten):**
|
||||||
|
- 📐 **Object Properties** (Würfel-Icon) - hier findest du Transform, etc.
|
||||||
|
- 🦴 **Armature Properties** (Bone-Icon) - hier findest du Armature-Einstellungen
|
||||||
|
- 🔧 **Modifier Properties** (Wrench-Icon) - hier sollte der **Rigify-Tab** sein!
|
||||||
|
- 🌍 **World Properties** (Globus-Icon) - NICHT hier suchen!
|
||||||
|
|
||||||
|
**Falls du den Rigify-Tab nicht siehst:**
|
||||||
|
- Stelle sicher, dass das **Meta-Rig** (nicht ein bereits generiertes Rig) ausgewählt ist
|
||||||
|
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
||||||
|
- Der Rigify-Tab sollte dort erscheinen
|
||||||
|
|
||||||
|
#### Option B: Manuelles Rigging
|
||||||
|
|
||||||
|
1. **Armature erstellen:**
|
||||||
|
- Drücke `Shift + A` → `Armature`
|
||||||
|
- Ein Bone wird erstellt
|
||||||
|
|
||||||
|
2. **Bones hinzufügen:**
|
||||||
|
- Wechsle in den **Edit Mode** (Tab)
|
||||||
|
- Wähle den Root-Bone aus
|
||||||
|
- Drücke `E` um einen neuen Bone zu extrudieren
|
||||||
|
- Erstelle die wichtigsten Bones:
|
||||||
|
- **Spine/Spine1/Spine2** - Wirbelsäule
|
||||||
|
- **Neck/Head** - Hals und Kopf
|
||||||
|
- **LeftArm/LeftForeArm/LeftHand** - Linker Arm
|
||||||
|
- **RightArm/RightForeArm/RightHand** - Rechter Arm
|
||||||
|
- **LeftUpLeg/LeftLeg/LeftFoot** - Linkes Bein
|
||||||
|
- **RightUpLeg/RightLeg/RightFoot** - Rechtes Bein
|
||||||
|
|
||||||
|
3. **Bone-Namen vergeben:**
|
||||||
|
- Wähle jeden Bone aus
|
||||||
|
- Im Properties-Panel (rechts) unter "Bone" kannst du den Namen ändern
|
||||||
|
- **Wichtig:** Verwende diese Namen für die Animation:
|
||||||
|
- `LeftArm`, `RightArm`
|
||||||
|
- `LeftForeArm`, `RightForeArm`
|
||||||
|
- `LeftHand`, `RightHand`
|
||||||
|
- `LeftUpLeg`, `RightUpLeg`
|
||||||
|
- `LeftLeg`, `RightLeg`
|
||||||
|
- `LeftFoot`, `RightFoot`
|
||||||
|
- `Neck`, `Head`
|
||||||
|
- `Spine`, `Spine1`, `Spine2`
|
||||||
|
|
||||||
|
### 4. Modell an Bones binden (Skinning)
|
||||||
|
|
||||||
|
1. **Beide Objekte auswählen:**
|
||||||
|
- Wähle zuerst das **Mesh** aus
|
||||||
|
- Dann wähle das **Armature** aus (Shift + Linksklick)
|
||||||
|
- Drücke `Ctrl + P` → `With Automatic Weights`
|
||||||
|
- Blender berechnet automatisch, welche Vertices zu welchen Bones gehören
|
||||||
|
|
||||||
|
2. **Weights überprüfen:**
|
||||||
|
- Wähle das Mesh aus
|
||||||
|
- Wechsle in den **Weight Paint Mode** (Dropdown oben)
|
||||||
|
- Wähle einen Bone aus (rechts im Properties-Panel)
|
||||||
|
- Rot = vollständig gebunden, Blau = nicht gebunden
|
||||||
|
- Falls nötig, kannst du die Weights manuell anpassen
|
||||||
|
|
||||||
|
### 5. Test-Animation erstellen (Optional)
|
||||||
|
|
||||||
|
1. **Pose Mode aktivieren:**
|
||||||
|
- Wähle das **generierte Rig** aus (nicht das Meta-Rig!)
|
||||||
|
- Wechsle in den **Pose Mode** (Dropdown oben: "Object Mode" → "Pose Mode")
|
||||||
|
- Oder: `Ctrl + Tab` → "Pose Mode"
|
||||||
|
|
||||||
|
2. **Bone auswählen:**
|
||||||
|
- **Wichtig:** Arbeite im **3D-Viewport** (Hauptfenster), nicht nur im Outliner!
|
||||||
|
- **Rigify-Bone-Namen** (nach dem Generieren):
|
||||||
|
- Für **Knie beugen**: `Leg.L (IK)` oder `Leg.L (FK)` (nicht "Tweak"!)
|
||||||
|
- Für **Hand anheben**: `Arm.L (IK)` oder `Arm.L (FK)`
|
||||||
|
- Für **Fuß bewegen**: `Leg.L (IK)` (der Fuß-Controller)
|
||||||
|
- **IK** = Inverse Kinematics (einfacher, empfohlen für Anfänger)
|
||||||
|
- **FK** = Forward Kinematics (mehr Kontrolle)
|
||||||
|
- **Tweak** = Feinabstimmungen (für später, nicht für Hauptanimationen)
|
||||||
|
- Klicke auf einen **Bone** im **3D-Viewport** (nicht im Outliner!)
|
||||||
|
- Der Bone sollte orange/ausgewählt sein und im Viewport sichtbar sein
|
||||||
|
- **Tipp:** Nutze `X-Ray Mode` (Button oben im Viewport) um Bones besser zu sehen
|
||||||
|
- **Tipp:** Im Outliner kannst du Bones finden, aber die Animation machst du im Viewport
|
||||||
|
|
||||||
|
3. **Bone animieren:**
|
||||||
|
- Wähle z.B. `hand.L` (linke Hand) aus
|
||||||
|
- Drücke `R` (Rotate) und rotiere den Bone
|
||||||
|
- Oder: `R` + `Z` (um Z-Achse rotieren)
|
||||||
|
- Oder: `R` + `X` (um X-Achse rotieren)
|
||||||
|
- Bewege die Maus → Linksklick zum Bestätigen
|
||||||
|
- **Beispiel für Hand anheben:** `hand.L` → `R` → `Z` → nach oben bewegen
|
||||||
|
|
||||||
|
4. **Animation aufnehmen (Timeline):**
|
||||||
|
- Unten siehst du die **Timeline** (falls nicht sichtbar: `Shift + F12` oder `Window` → `Animation` → `Timeline`)
|
||||||
|
- Stelle den Frame auf **1** (Anfang)
|
||||||
|
- Wähle den Bone aus und positioniere ihn in der **Ausgangsposition**
|
||||||
|
- Drücke `I` (Insert Keyframe) → wähle **"Rotation"** (oder "Location" falls bewegt)
|
||||||
|
- Ein Keyframe wird erstellt (gelber Punkt in der Timeline)
|
||||||
|
- Stelle den Frame auf **30** (oder einen anderen Frame)
|
||||||
|
- Rotiere/Bewege den Bone in die **Zielposition** (z.B. Hand nach oben)
|
||||||
|
- Drücke wieder `I` → **"Rotation"** (oder "Location")
|
||||||
|
- Stelle den Frame auf **60** (Rückkehr zur Ausgangsposition)
|
||||||
|
- Rotiere den Bone zurück zur Ausgangsposition
|
||||||
|
- Drücke `I` → **"Rotation"**
|
||||||
|
- Drücke **Play** (Leertaste) um die Animation zu sehen
|
||||||
|
|
||||||
|
5. **Animation testen:**
|
||||||
|
- Die Animation sollte jetzt in einer Schleife abgespielt werden
|
||||||
|
- Du kannst weitere Keyframes hinzufügen (Frame 90, 120, etc.)
|
||||||
|
- **Tipp:** Nutze `Alt + A` um die Animation zu stoppen
|
||||||
|
|
||||||
|
### 6. Modell exportieren
|
||||||
|
|
||||||
|
1. **Beide Objekte auswählen:**
|
||||||
|
- Wähle das **Mesh** aus
|
||||||
|
- Shift + Linksklick auf das **generierte Rig** (nicht das Meta-Rig!)
|
||||||
|
|
||||||
|
2. **Exportieren:**
|
||||||
|
- Gehe zu `File` → `Export` → `glTF 2.0 (.glb/.gltf)`
|
||||||
|
- Wähle `.glb` Format
|
||||||
|
- Stelle sicher, dass folgende Optionen aktiviert sind:
|
||||||
|
- ✅ **Include** → **Selected Objects**
|
||||||
|
- ✅ **Transform** → **+Y Up**
|
||||||
|
- ✅ **Geometry** → **Apply Modifiers**
|
||||||
|
- ✅ **Animation** → **Bake Animation** (wichtig für Animationen!)
|
||||||
|
- ✅ **Animation** → **Always Sample Animations** (falls Animationen nicht korrekt exportiert werden)
|
||||||
|
- Klicke auf "Export glTF 2.0"
|
||||||
|
|
||||||
|
### 7. Modell testen
|
||||||
|
|
||||||
|
1. Kopiere die exportierte `.glb` Datei nach:
|
||||||
|
```
|
||||||
|
frontend/public/models/3d/falukant/characters/
|
||||||
|
```
|
||||||
|
2. Lade die Seite neu
|
||||||
|
3. Die Bones sollten jetzt automatisch erkannt und animiert werden
|
||||||
|
4. **Animationen testen:**
|
||||||
|
- Öffne die Browser-Konsole (F12)
|
||||||
|
- Du solltest sehen: `[ThreeScene] Found X animation(s)`
|
||||||
|
- Die Animationen sollten automatisch abgespielt werden
|
||||||
|
- Falls keine Animationen vorhanden sind, werden die Bones trotzdem mit Idle-Animationen bewegt
|
||||||
|
|
||||||
|
## Rig anpassen - Detaillierte Anleitung
|
||||||
|
|
||||||
|
### Rig nach dem Generieren anpassen
|
||||||
|
|
||||||
|
Wenn das Rigify-Rig generiert wurde, aber nicht perfekt passt:
|
||||||
|
|
||||||
|
1. **Pose Mode verwenden:**
|
||||||
|
- Wähle das generierte Armature aus
|
||||||
|
- Wechsle in den **Pose Mode** (Dropdown oben, oder Strg+Tab → Pose Mode)
|
||||||
|
- Hier kannst du die Bones bewegen, ohne die Struktur zu zerstören
|
||||||
|
|
||||||
|
2. **Rig neu generieren (falls nötig):**
|
||||||
|
- Falls das Rig komplett neu positioniert werden muss:
|
||||||
|
- Lösche das generierte Rig (X → Delete)
|
||||||
|
- Gehe zurück zum Meta-Rig
|
||||||
|
- Passe das Meta-Rig im Edit Mode an
|
||||||
|
- Generiere das Rig erneut
|
||||||
|
|
||||||
|
3. **Snap to Mesh (Hilfsmittel):**
|
||||||
|
- Im Edit Mode: `Shift + Tab` um Snap zu aktivieren
|
||||||
|
- Oder: Rechtsklick auf das Snap-Symbol (Magnet) oben
|
||||||
|
- Wähle "Face" oder "Vertex" als Snap-Target
|
||||||
|
- Jetzt werden Bones automatisch am Mesh ausgerichtet
|
||||||
|
|
||||||
|
### Häufige Probleme und Lösungen
|
||||||
|
|
||||||
|
**Problem: Rig ist zu groß/klein**
|
||||||
|
- Lösung: Im Object Mode das Armature auswählen und mit `S` skalieren
|
||||||
|
|
||||||
|
**Problem: Rig ist an falscher Position**
|
||||||
|
- Lösung: Im Object Mode mit `G` bewegen, oder `Alt + G` zurücksetzen
|
||||||
|
|
||||||
|
**Problem: Einzelne Bones passen nicht**
|
||||||
|
- Lösung: Im Edit Mode die Bones einzeln anpassen (`G` zum Bewegen)
|
||||||
|
|
||||||
|
**Problem: Nach dem Generieren passt es nicht mehr**
|
||||||
|
- Lösung: Passe das Meta-Rig an und generiere neu, oder verwende Pose Mode
|
||||||
|
|
||||||
|
## Tipps und Tricks
|
||||||
|
|
||||||
|
### Bone-Namen für automatische Erkennung
|
||||||
|
|
||||||
|
Die Komponente erkennt Bones anhand ihrer Namen. Verwende diese Keywords:
|
||||||
|
- `arm` - für Arme
|
||||||
|
- `hand` oder `wrist` - für Hände
|
||||||
|
- `leg` oder `knee` - für Beine
|
||||||
|
- `foot` oder `ankle` - für Füße
|
||||||
|
- `shoulder` - für Schultern
|
||||||
|
- `elbow` - für Ellbogen
|
||||||
|
|
||||||
|
### Einfacheres Rigging mit Mixamo
|
||||||
|
|
||||||
|
Alternativ kannst du:
|
||||||
|
1. Dein Modell auf [Mixamo](https://www.mixamo.com/) hochladen
|
||||||
|
2. Automatisches Rigging durchführen lassen
|
||||||
|
3. Das geriggte Modell herunterladen
|
||||||
|
4. In Blender importieren und anpassen
|
||||||
|
|
||||||
|
### Performance-Optimierung
|
||||||
|
|
||||||
|
- Verwende nicht zu viele Bones (max. 50-100 für Charaktere)
|
||||||
|
- Entferne unnötige Bones vor dem Export
|
||||||
|
- Teste die Animation im Browser, bevor du das finale Modell exportierst
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bones werden nicht erkannt
|
||||||
|
|
||||||
|
- Prüfe die Bone-Namen (müssen `arm`, `hand`, `leg`, etc. enthalten)
|
||||||
|
- Stelle sicher, dass das Modell korrekt an die Bones gebunden ist
|
||||||
|
- Öffne die Browser-Konsole und prüfe die Logs: `[ThreeScene] Found X bones for animation`
|
||||||
|
|
||||||
|
### Modell verformt sich falsch
|
||||||
|
|
||||||
|
- Überprüfe die Weights im Weight Paint Mode
|
||||||
|
- Passe die Bone-Positionen an
|
||||||
|
- Stelle sicher, dass alle Vertices korrekt zugewiesen sind
|
||||||
|
|
||||||
|
### Export schlägt fehl
|
||||||
|
|
||||||
|
- Stelle sicher, dass beide Objekte (Mesh + Armature) ausgewählt sind
|
||||||
|
- Prüfe, ob das Modell im Object Mode ist
|
||||||
|
- Versuche es mit einem anderen Export-Format (.gltf statt .glb)
|
||||||
|
|
||||||
|
## Weitere Ressourcen
|
||||||
|
|
||||||
|
- [Blender Rigging Tutorial](https://www.youtube.com/results?search_query=blender+rigging+tutorial)
|
||||||
|
- [Mixamo Auto-Rigging](https://www.mixamo.com/)
|
||||||
|
- [Three.js GLTF Animation Guide](https://threejs.org/docs/#manual/en/introduction/Animation-system)
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"three": "^0.182.0",
|
||||||
"vue": "~3.4.31",
|
"vue": "~3.4.31",
|
||||||
"vue-i18n": "^10.0.0-beta.2",
|
"vue-i18n": "^10.0.0-beta.2",
|
||||||
"vue-multiselect": "^3.1.0",
|
"vue-multiselect": "^3.1.0",
|
||||||
@@ -2834,6 +2835,12 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"three": "^0.182.0",
|
||||||
"vue": "~3.4.31",
|
"vue": "~3.4.31",
|
||||||
"vue-i18n": "^10.0.0-beta.2",
|
"vue-i18n": "^10.0.0-beta.2",
|
||||||
"vue-multiselect": "^3.1.0",
|
"vue-multiselect": "^3.1.0",
|
||||||
|
|||||||
40
frontend/public/models/3d/falukant/characters/README.md
Normal file
40
frontend/public/models/3d/falukant/characters/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 3D-Charakter-Modelle
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
Dieses Verzeichnis enthält die 3D-Modelle für Falukant-Charaktere.
|
||||||
|
|
||||||
|
## Dateinamen-Konvention
|
||||||
|
|
||||||
|
### Basis-Modelle (Fallback)
|
||||||
|
- `male.glb` - Basis-Modell männlich
|
||||||
|
- `female.glb` - Basis-Modell weiblich
|
||||||
|
|
||||||
|
### Altersspezifische Modelle
|
||||||
|
- `male_toddler.glb` - Männlich, Kleinkind (0-3 Jahre)
|
||||||
|
- `male_child.glb` - Männlich, Kind (4-7 Jahre)
|
||||||
|
- `male_preteen.glb` - Männlich, Vor-Teenager (8-12 Jahre)
|
||||||
|
- `male_teen.glb` - Männlich, Teenager (13-17 Jahre)
|
||||||
|
- `male_adult.glb` - Männlich, Erwachsen (18+ Jahre)
|
||||||
|
- `female_toddler.glb` - Weiblich, Kleinkind (0-3 Jahre)
|
||||||
|
- `female_child.glb` - Weiblich, Kind (4-7 Jahre)
|
||||||
|
- `female_preteen.glb` - Weiblich, Vor-Teenager (8-12 Jahre)
|
||||||
|
- `female_teen.glb` - Weiblich, Teenager (13-17 Jahre)
|
||||||
|
- `female_adult.glb` - Weiblich, Erwachsen (18+ Jahre)
|
||||||
|
|
||||||
|
## Fallback-Verhalten
|
||||||
|
|
||||||
|
Wenn kein spezifisches Modell für den Altersbereich existiert, wird automatisch das Basis-Modell (`male.glb` / `female.glb`) verwendet.
|
||||||
|
|
||||||
|
## Dateigröße
|
||||||
|
|
||||||
|
- Empfohlen: < 500KB pro Modell
|
||||||
|
- Maximal: 1MB pro Modell
|
||||||
|
|
||||||
|
## Optimierung
|
||||||
|
|
||||||
|
Vor dem Hochladen:
|
||||||
|
1. In Blender öffnen
|
||||||
|
2. Decimate Modifier anwenden (falls nötig)
|
||||||
|
3. Texturen komprimieren (WebP, max 1024x1024)
|
||||||
|
4. GLB Export mit Compression aktiviert
|
||||||
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
Binary file not shown.
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal file
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div class="character-model-3d">
|
||||||
|
<ThreeScene
|
||||||
|
v-if="currentModelPath"
|
||||||
|
:key="currentModelPath"
|
||||||
|
:modelPath="currentModelPath"
|
||||||
|
:autoRotate="autoRotate"
|
||||||
|
:rotationSpeed="rotationSpeed"
|
||||||
|
:cameraPosition="cameraPosition"
|
||||||
|
:backgroundColor="backgroundColor"
|
||||||
|
@model-loaded="onModelLoaded"
|
||||||
|
@model-error="onModelError"
|
||||||
|
@loading-progress="onLoadingProgress"
|
||||||
|
/>
|
||||||
|
<div v-if="loading" class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p v-if="loadingProgress > 0">{{ Math.round(loadingProgress) }}%</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="error-overlay">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ThreeScene from './ThreeScene.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CharacterModel3D',
|
||||||
|
components: {
|
||||||
|
ThreeScene
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
gender: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => ['male', 'female'].includes(value)
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
autoRotate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
rotationSpeed: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.5
|
||||||
|
},
|
||||||
|
cameraPosition: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 1, z: 3 })
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#f0f0f0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
loadingProgress: 0,
|
||||||
|
error: null,
|
||||||
|
currentModelPath: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
baseModelPath() {
|
||||||
|
const basePath = '/models/3d/falukant/characters';
|
||||||
|
return `${basePath}/${this.gender}.glb`;
|
||||||
|
},
|
||||||
|
ageSpecificModelPath() {
|
||||||
|
const ageRange = this.getAgeRange(this.age);
|
||||||
|
if (!ageRange) return null;
|
||||||
|
|
||||||
|
const basePath = '/models/3d/falukant/characters';
|
||||||
|
return `${basePath}/${this.gender}_${ageRange}.glb`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
gender() {
|
||||||
|
this.findAndLoadModel();
|
||||||
|
},
|
||||||
|
age() {
|
||||||
|
this.findAndLoadModel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.findAndLoadModel();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getAgeRange(age) {
|
||||||
|
if (age === null || age === undefined) return null;
|
||||||
|
|
||||||
|
// Verfügbare Altersbereiche: toddler, child, preteen, teen, adult
|
||||||
|
// Alter ist in Tagen gespeichert (1 Tag = 1 Jahr)
|
||||||
|
if (age < 4) return 'toddler'; // 0-3 Jahre
|
||||||
|
if (age < 10) return 'child'; // 4-7 Jahre
|
||||||
|
if (age < 13) return 'preteen'; // 8-12 Jahre
|
||||||
|
if (age < 18) return 'teen'; // 13-17 Jahre
|
||||||
|
return 'adult'; // 18+ Jahre
|
||||||
|
},
|
||||||
|
|
||||||
|
async findAndLoadModel() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
// Versuche zuerst altersspezifisches Modell, dann Basis-Modell
|
||||||
|
const pathsToTry = [];
|
||||||
|
if (this.ageSpecificModelPath) {
|
||||||
|
pathsToTry.push(this.ageSpecificModelPath);
|
||||||
|
}
|
||||||
|
pathsToTry.push(this.baseModelPath);
|
||||||
|
|
||||||
|
// Prüfe welche Datei existiert
|
||||||
|
for (const path of pathsToTry) {
|
||||||
|
const exists = await this.checkFileExists(path);
|
||||||
|
if (exists) {
|
||||||
|
this.currentModelPath = path;
|
||||||
|
console.log(`[CharacterModel3D] Using model: ${path}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Verwende Basis-Modell auch wenn Prüfung fehlschlägt
|
||||||
|
this.currentModelPath = this.baseModelPath;
|
||||||
|
console.warn(`[CharacterModel3D] Using fallback model: ${this.baseModelPath}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkFileExists(path) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, { method: 'HEAD' });
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Content-Type - sollte nicht HTML sein
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const isHTML = contentType.includes('text/html') || contentType.includes('text/plain');
|
||||||
|
|
||||||
|
if (isHTML) {
|
||||||
|
console.warn(`[CharacterModel3D] File ${path} returns HTML, probably doesn't exist`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLB-Dateien können verschiedene Content-Types haben
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[CharacterModel3D] Error checking file ${path}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onModelLoaded(model) {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
this.$emit('model-loaded', model);
|
||||||
|
},
|
||||||
|
|
||||||
|
onModelError(error) {
|
||||||
|
// Wenn ein Fehler auftritt und wir noch nicht das Basis-Modell verwenden
|
||||||
|
if (this.currentModelPath !== this.baseModelPath) {
|
||||||
|
console.warn('[CharacterModel3D] Model failed, trying fallback...');
|
||||||
|
this.currentModelPath = this.baseModelPath;
|
||||||
|
// Der Watch-Handler wird das Modell neu laden
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.error = 'Fehler beim Laden des 3D-Modells';
|
||||||
|
console.error('Character model error:', error);
|
||||||
|
this.$emit('model-error', error);
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoadingProgress(progress) {
|
||||||
|
this.loadingProgress = progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.character-model-3d {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay,
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #F9A22C;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay p {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
441
frontend/src/components/falukant/ThreeScene.vue
Normal file
441
frontend/src/components/falukant/ThreeScene.vue
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="three-scene-container"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { markRaw } from 'vue';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ThreeScene',
|
||||||
|
props: {
|
||||||
|
modelPath: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
autoRotate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
rotationSpeed: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.5
|
||||||
|
},
|
||||||
|
cameraPosition: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 1, z: 3 })
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#f0f0f0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
scene: null,
|
||||||
|
camera: null,
|
||||||
|
renderer: null,
|
||||||
|
controls: null,
|
||||||
|
model: null,
|
||||||
|
animationId: null,
|
||||||
|
mixer: null,
|
||||||
|
clock: null,
|
||||||
|
animationStartTime: 0,
|
||||||
|
baseY: 0, // Basis-Y-Position für Bewegungsanimation
|
||||||
|
bones: [] // Gespeicherte Bones für manuelle Animation
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initScene();
|
||||||
|
this.loadModel();
|
||||||
|
this.animate();
|
||||||
|
window.addEventListener('resize', this.onWindowResize);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
window.removeEventListener('resize', this.onWindowResize);
|
||||||
|
if (this.animationId) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
}
|
||||||
|
if (this.mixer) {
|
||||||
|
this.mixer.stopAllAction();
|
||||||
|
}
|
||||||
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose();
|
||||||
|
}
|
||||||
|
if (this.model) {
|
||||||
|
this.disposeModel(this.model);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelPath() {
|
||||||
|
if (this.model) {
|
||||||
|
this.disposeModel(this.model);
|
||||||
|
this.model = null;
|
||||||
|
}
|
||||||
|
this.loadModel();
|
||||||
|
},
|
||||||
|
autoRotate(newVal) {
|
||||||
|
if (this.controls) {
|
||||||
|
this.controls.autoRotate = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initScene() {
|
||||||
|
// Szene erstellen - markRaw verhindert Vue-Reaktivität
|
||||||
|
this.scene = markRaw(new THREE.Scene());
|
||||||
|
this.scene.background = new THREE.Color(this.backgroundColor);
|
||||||
|
|
||||||
|
// Kamera erstellen - markRaw verhindert Vue-Reaktivität
|
||||||
|
this.camera = markRaw(new THREE.PerspectiveCamera(
|
||||||
|
50,
|
||||||
|
this.$refs.container.clientWidth / this.$refs.container.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
));
|
||||||
|
this.camera.position.set(
|
||||||
|
this.cameraPosition.x,
|
||||||
|
this.cameraPosition.y,
|
||||||
|
this.cameraPosition.z
|
||||||
|
);
|
||||||
|
|
||||||
|
// Renderer erstellen - markRaw verhindert Vue-Reaktivität
|
||||||
|
this.renderer = markRaw(new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
|
}));
|
||||||
|
this.renderer.setSize(
|
||||||
|
this.$refs.container.clientWidth,
|
||||||
|
this.$refs.container.clientHeight
|
||||||
|
);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Begrenzt für Performance
|
||||||
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
this.renderer.toneMappingExposure = 1.2; // Leicht erhöhte Helligkeit
|
||||||
|
this.$refs.container.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
|
// Controls erstellen - markRaw verhindert Vue-Reaktivität
|
||||||
|
this.controls = markRaw(new OrbitControls(this.camera, this.renderer.domElement));
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.05;
|
||||||
|
this.controls.autoRotate = false; // Rotation deaktiviert
|
||||||
|
this.controls.enableRotate = false; // Manuelle Rotation deaktiviert
|
||||||
|
this.controls.enableZoom = true;
|
||||||
|
this.controls.enablePan = false;
|
||||||
|
this.controls.minDistance = 2;
|
||||||
|
this.controls.maxDistance = 5;
|
||||||
|
|
||||||
|
// Clock für Animationen
|
||||||
|
this.clock = markRaw(new THREE.Clock());
|
||||||
|
|
||||||
|
// Verbesserte Beleuchtung
|
||||||
|
// Umgebungslicht - heller für bessere Sichtbarkeit
|
||||||
|
const ambientLight = markRaw(new THREE.AmbientLight(0xffffff, 1.0));
|
||||||
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
|
// Hauptlicht von vorne oben (Key Light)
|
||||||
|
const mainLight = markRaw(new THREE.DirectionalLight(0xffffff, 1.2));
|
||||||
|
mainLight.position.set(3, 8, 4);
|
||||||
|
mainLight.castShadow = true;
|
||||||
|
mainLight.shadow.mapSize.width = 2048;
|
||||||
|
mainLight.shadow.mapSize.height = 2048;
|
||||||
|
mainLight.shadow.camera.near = 0.5;
|
||||||
|
mainLight.shadow.camera.far = 50;
|
||||||
|
this.scene.add(mainLight);
|
||||||
|
|
||||||
|
// Fülllicht von links (Fill Light)
|
||||||
|
const fillLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.6));
|
||||||
|
fillLight.position.set(-4, 5, 3);
|
||||||
|
this.scene.add(fillLight);
|
||||||
|
|
||||||
|
// Zusätzliches Licht von rechts (Rim Light)
|
||||||
|
const rimLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.5));
|
||||||
|
rimLight.position.set(4, 3, -3);
|
||||||
|
this.scene.add(rimLight);
|
||||||
|
|
||||||
|
// Punktlicht von oben für zusätzliche Helligkeit
|
||||||
|
const pointLight = markRaw(new THREE.PointLight(0xffffff, 0.8, 20));
|
||||||
|
pointLight.position.set(0, 6, 0);
|
||||||
|
this.scene.add(pointLight);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadModel() {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
|
||||||
|
// Optional: DRACO-Loader für komprimierte Modelle
|
||||||
|
// const dracoLoader = new DRACOLoader();
|
||||||
|
// dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
|
||||||
|
// loader.setDRACOLoader(dracoLoader);
|
||||||
|
|
||||||
|
console.log('[ThreeScene] Loading model from:', this.modelPath);
|
||||||
|
console.log('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
|
||||||
|
|
||||||
|
loader.load(
|
||||||
|
this.modelPath,
|
||||||
|
(gltf) => {
|
||||||
|
console.log('[ThreeScene] Model loaded successfully:', gltf);
|
||||||
|
|
||||||
|
// Altes Modell entfernen
|
||||||
|
if (this.model) {
|
||||||
|
this.scene.remove(this.model);
|
||||||
|
this.disposeModel(this.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modell als nicht-reaktiv markieren - verhindert Vue-Proxy-Konflikte
|
||||||
|
this.model = markRaw(gltf.scene);
|
||||||
|
|
||||||
|
// Modell zentrieren und skalieren
|
||||||
|
const box = new THREE.Box3().setFromObject(this.model);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
console.log('[ThreeScene] Model bounds:', { center, size });
|
||||||
|
|
||||||
|
// Modell zentrieren (X und Z)
|
||||||
|
this.model.position.x = -center.x;
|
||||||
|
this.model.position.z = -center.z;
|
||||||
|
|
||||||
|
// Modell skalieren (größer für bessere Sichtbarkeit)
|
||||||
|
const maxSize = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = maxSize > 0 ? 3.0 / maxSize : 1;
|
||||||
|
this.model.scale.multiplyScalar(scale);
|
||||||
|
|
||||||
|
// Modell auf Boden setzen und Basis-Y-Position speichern
|
||||||
|
this.baseY = -size.y * scale / 2;
|
||||||
|
this.model.position.y = this.baseY;
|
||||||
|
|
||||||
|
// Schatten aktivieren
|
||||||
|
this.model.traverse((child) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.add(this.model);
|
||||||
|
|
||||||
|
// Kamera auf Modell ausrichten
|
||||||
|
this.centerCameraOnModel();
|
||||||
|
|
||||||
|
// Bones für manuelle Animation finden
|
||||||
|
this.findAndStoreBones(this.model);
|
||||||
|
|
||||||
|
// Falls keine Bones gefunden, Hinweis in der Konsole
|
||||||
|
if (this.bones.length === 0) {
|
||||||
|
console.warn('[ThreeScene] No bones found in model. To enable limb animations, add bones in Blender. See docs/BLENDER_RIGGING_GUIDE.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animationen aus GLTF laden (falls vorhanden)
|
||||||
|
if (gltf.animations && gltf.animations.length > 0) {
|
||||||
|
console.log(`[ThreeScene] Found ${gltf.animations.length} animation(s):`, gltf.animations.map(a => a.name));
|
||||||
|
this.mixer = markRaw(new THREE.AnimationMixer(this.model));
|
||||||
|
gltf.animations.forEach((clip) => {
|
||||||
|
const action = this.mixer.clipAction(clip);
|
||||||
|
action.play();
|
||||||
|
console.log(`[ThreeScene] Playing animation: "${clip.name}" (duration: ${clip.duration.toFixed(2)}s)`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[ThreeScene] No animations found in model');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationStartTime = this.clock.getElapsedTime();
|
||||||
|
this.$emit('model-loaded', this.model);
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
// Loading-Progress
|
||||||
|
if (progress.lengthComputable) {
|
||||||
|
const percent = (progress.loaded / progress.total) * 100;
|
||||||
|
this.$emit('loading-progress', percent);
|
||||||
|
} else {
|
||||||
|
// Fallback für nicht-computable progress
|
||||||
|
this.$emit('loading-progress', 50);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[ThreeScene] Error loading model:', error);
|
||||||
|
console.error('[ThreeScene] Model path was:', this.modelPath);
|
||||||
|
console.error('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
|
||||||
|
console.error('[ThreeScene] Error details:', {
|
||||||
|
message: error?.message,
|
||||||
|
stack: error?.stack,
|
||||||
|
type: error?.constructor?.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prüfe ob es ein 404-Fehler ist (JSON-Parse-Fehler deutet auf HTML-Fehlerseite hin)
|
||||||
|
if (error?.message && (error.message.includes('JSON') || error.message.includes('Unexpected'))) {
|
||||||
|
console.error('[ThreeScene] Possible 404 error - file not found or wrong path');
|
||||||
|
console.error('[ThreeScene] Please check:');
|
||||||
|
console.error(' 1. File exists at:', this.modelPath);
|
||||||
|
console.error(' 2. Vite dev server is running');
|
||||||
|
console.error(' 3. File is in public/ directory');
|
||||||
|
|
||||||
|
// Versuche die Datei direkt zu fetchen um den Fehler zu sehen
|
||||||
|
fetch(this.modelPath)
|
||||||
|
.then(response => {
|
||||||
|
console.error('[ThreeScene] Fetch response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries())
|
||||||
|
});
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(text => {
|
||||||
|
console.error('[ThreeScene] Response preview:', text.substring(0, 200));
|
||||||
|
})
|
||||||
|
.catch(fetchError => {
|
||||||
|
console.error('[ThreeScene] Fetch error:', fetchError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('model-error', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
disposeModel(model) {
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((mat) => mat.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
findAndStoreBones(object) {
|
||||||
|
this.bones = [];
|
||||||
|
|
||||||
|
object.traverse((child) => {
|
||||||
|
if (child.isBone || (child.type === 'Bone')) {
|
||||||
|
// Speichere Bones mit ihren Namen für einfachen Zugriff
|
||||||
|
const boneName = child.name.toLowerCase();
|
||||||
|
|
||||||
|
// Typische Bone-Namen für Gliedmaßen
|
||||||
|
if (boneName.includes('arm') ||
|
||||||
|
boneName.includes('hand') ||
|
||||||
|
boneName.includes('leg') ||
|
||||||
|
boneName.includes('foot') ||
|
||||||
|
boneName.includes('shoulder') ||
|
||||||
|
boneName.includes('elbow') ||
|
||||||
|
boneName.includes('knee') ||
|
||||||
|
boneName.includes('wrist') ||
|
||||||
|
boneName.includes('ankle')) {
|
||||||
|
this.bones.push({
|
||||||
|
bone: child,
|
||||||
|
name: boneName,
|
||||||
|
originalRotation: child.rotation.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[ThreeScene] Found ${this.bones.length} bones for animation`);
|
||||||
|
},
|
||||||
|
|
||||||
|
animateLimbs(time) {
|
||||||
|
// Sanfte Idle-Animation für Gliedmaßen
|
||||||
|
const animationSpeed = 1.5; // Geschwindigkeit
|
||||||
|
const maxRotation = 0.15; // Maximale Rotation in Radianten (ca. 8.6 Grad)
|
||||||
|
|
||||||
|
this.bones.forEach((boneData, index) => {
|
||||||
|
const bone = boneData.bone;
|
||||||
|
const boneName = boneData.name;
|
||||||
|
|
||||||
|
// Unterschiedliche Animationen basierend auf Bone-Typ
|
||||||
|
if (boneName.includes('arm') || boneName.includes('shoulder')) {
|
||||||
|
// Arme: Sanftes Vor- und Zurückschwingen
|
||||||
|
const phase = time * animationSpeed + (index * 0.5);
|
||||||
|
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.3;
|
||||||
|
bone.rotation.z = boneData.originalRotation.z + Math.cos(phase * 0.7) * maxRotation * 0.2;
|
||||||
|
} else if (boneName.includes('hand') || boneName.includes('wrist')) {
|
||||||
|
// Hände: Leichtes Wackeln
|
||||||
|
const phase = time * animationSpeed * 1.5 + (index * 0.3);
|
||||||
|
bone.rotation.y = boneData.originalRotation.y + Math.sin(phase) * maxRotation * 0.4;
|
||||||
|
} else if (boneName.includes('leg') || boneName.includes('knee')) {
|
||||||
|
// Beine: Leichtes Vor- und Zurückbewegen
|
||||||
|
const phase = time * animationSpeed * 0.8 + (index * 0.4);
|
||||||
|
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.2;
|
||||||
|
} else if (boneName.includes('foot') || boneName.includes('ankle')) {
|
||||||
|
// Füße: Minimales Wackeln
|
||||||
|
const phase = time * animationSpeed * 1.2 + (index * 0.2);
|
||||||
|
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
centerCameraOnModel() {
|
||||||
|
if (!this.model || !this.camera) return;
|
||||||
|
|
||||||
|
// Kamera-Position für gute Ansicht des zentrierten Modells
|
||||||
|
this.camera.position.set(0, this.baseY + 1, 3);
|
||||||
|
this.camera.lookAt(0, this.baseY + 0.5, 0);
|
||||||
|
|
||||||
|
if (this.controls) {
|
||||||
|
this.controls.target.set(0, this.baseY + 0.5, 0);
|
||||||
|
this.controls.update();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
animate() {
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
const delta = this.clock ? this.clock.getDelta() : 0;
|
||||||
|
|
||||||
|
// GLTF-Animationen aktualisieren (falls vorhanden)
|
||||||
|
if (this.mixer) {
|
||||||
|
this.mixer.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gliedmaßen-Animationen
|
||||||
|
if (this.bones.length > 0) {
|
||||||
|
const time = this.clock ? this.clock.getElapsedTime() : 0;
|
||||||
|
this.animateLimbs(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controls) {
|
||||||
|
this.controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.renderer && this.scene && this.camera) {
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onWindowResize() {
|
||||||
|
if (!this.$refs.container || !this.camera || !this.renderer) return;
|
||||||
|
|
||||||
|
const width = this.$refs.container.clientWidth;
|
||||||
|
const height = this.$refs.container.clientHeight;
|
||||||
|
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.three-scene-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-scene-container canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -468,10 +468,14 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
activeTab(newVal) {
|
activeTab(newVal, oldVal) {
|
||||||
if (newVal === 'taxes') {
|
// Nur neu laden, wenn der Tab wirklich gewechselt wurde und ein Branch ausgewählt ist
|
||||||
this.loadBranchTaxes();
|
if (!this.selectedBranch || newVal === oldVal) return;
|
||||||
}
|
|
||||||
|
// Alle Tabs neu laden, wenn gewechselt wird
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.refreshActiveTab();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
selectedBranch: {
|
selectedBranch: {
|
||||||
handler(newBranch) {
|
handler(newBranch) {
|
||||||
@@ -537,6 +541,33 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshActiveTab() {
|
||||||
|
// Lade die Daten für den aktiven Tab neu
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'director':
|
||||||
|
this.$refs.directorInfo?.refresh();
|
||||||
|
break;
|
||||||
|
case 'inventory':
|
||||||
|
this.$refs.saleSection?.loadInventory();
|
||||||
|
this.$refs.saleSection?.loadTransports();
|
||||||
|
break;
|
||||||
|
case 'production':
|
||||||
|
this.$refs.productionSection?.loadProductions();
|
||||||
|
this.$refs.productionSection?.loadStorage();
|
||||||
|
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
||||||
|
break;
|
||||||
|
case 'taxes':
|
||||||
|
this.loadBranchTaxes();
|
||||||
|
break;
|
||||||
|
case 'storage':
|
||||||
|
this.$refs.storageSection?.loadStorageData();
|
||||||
|
break;
|
||||||
|
case 'transport':
|
||||||
|
this.loadVehicles();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async onBranchSelected(newBranch) {
|
async onBranchSelected(newBranch) {
|
||||||
this.selectedBranch = newBranch;
|
this.selectedBranch = newBranch;
|
||||||
// Branches neu laden, um das Wetter zu aktualisieren
|
// Branches neu laden, um das Wetter zu aktualisieren
|
||||||
@@ -549,13 +580,8 @@ export default {
|
|||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
await this.loadProductPricesForCurrentBranch();
|
await this.loadProductPricesForCurrentBranch();
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.directorInfo?.refresh();
|
// Alle Tabs neu laden
|
||||||
this.$refs.saleSection?.loadInventory();
|
this.refreshActiveTab();
|
||||||
this.$refs.saleSection?.loadTransports();
|
|
||||||
this.$refs.productionSection?.loadProductions();
|
|
||||||
this.$refs.productionSection?.loadStorage();
|
|
||||||
this.$refs.storageSection?.loadStorageData();
|
|
||||||
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// load tax info for this branch
|
// load tax info for this branch
|
||||||
|
|||||||
@@ -295,8 +295,13 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadGifts() {
|
async loadGifts() {
|
||||||
const response = await apiClient.get('/api/falukant/family/gifts');
|
try {
|
||||||
this.gifts = response.data;
|
const response = await apiClient.get('/api/falukant/family/gifts');
|
||||||
|
this.gifts = response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading gifts:', error);
|
||||||
|
this.gifts = []; // Leeres Array bei Fehler
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendGift() {
|
async sendGift() {
|
||||||
|
|||||||
@@ -116,15 +116,26 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="falukantUser?.character" class="imagecontainer">
|
<div v-if="falukantUser?.character" class="overview-visualization">
|
||||||
<div :style="getAvatarStyle" class="avatar"></div>
|
<div class="character-3d-container">
|
||||||
<div :style="getHouseStyle" class="house"></div>
|
<CharacterModel3D
|
||||||
|
:gender="falukantUser.character.gender"
|
||||||
|
:age="falukantUser.character.age"
|
||||||
|
:autoRotate="true"
|
||||||
|
:rotationSpeed="0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="imagecontainer">
|
||||||
|
<div :style="getAvatarStyle" class="avatar"></div>
|
||||||
|
<div :style="getHouseStyle" class="house"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||||
|
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
|
|
||||||
@@ -169,6 +180,7 @@ export default {
|
|||||||
name: 'FalukantOverviewView',
|
name: 'FalukantOverviewView',
|
||||||
components: {
|
components: {
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
CharacterModel3D,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -481,4 +493,27 @@ h2 {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-visualization {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-3d-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 500px;
|
||||||
|
height: 400px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imagecontainer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,5 +50,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
assert: 'assert',
|
assert: 'assert',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
// Erlaube Zugriff auf Dateien außerhalb des Projektverzeichnisses
|
||||||
|
strict: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Stelle sicher, dass GLB/GLTF-Dateien als Assets behandelt werden
|
||||||
|
assetsInclude: ['**/*.glb', '**/*.gltf']
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user