Compare commits
27 Commits
falukant-3
...
cd739fb52e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd739fb52e | ||
|
|
9e845843d8 | ||
|
|
0cc280ed55 | ||
|
|
b3707d21b2 | ||
|
|
fbebd6c1c1 | ||
|
|
d7c2bda461 | ||
|
|
2bf949513b | ||
|
|
84619fb656 | ||
|
|
b600f16ecd | ||
|
|
9273066f61 | ||
|
|
7d59dbcf84 | ||
|
|
015d1ae95b | ||
|
|
e2cd6e0e5e | ||
|
|
ec113058d0 | ||
|
|
d2ac2bfdd8 | ||
|
|
d75fe18e6a | ||
|
|
479f222b54 | ||
|
|
013c536b47 | ||
|
|
3b983a0db5 | ||
|
|
5f9559ac8d | ||
|
|
f487e6d765 | ||
|
|
5e26422e9c | ||
|
|
64baebfaaa | ||
|
|
521dec24b2 | ||
|
|
36f0bd8eb9 | ||
|
|
d0a2b122b2 | ||
|
|
c80cc8ec86 |
@@ -162,6 +162,18 @@ class FalukantController {
|
||||
}
|
||||
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
||||
});
|
||||
this.getProductPricesInRegionBatch = this._wrapWithUser((userId, req) => {
|
||||
const productIds = req.query.productIds;
|
||||
const regionId = parseInt(req.query.regionId, 10);
|
||||
if (!productIds || Number.isNaN(regionId)) {
|
||||
throw new Error('productIds (comma-separated) and regionId are required');
|
||||
}
|
||||
const productIdArray = productIds.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !Number.isNaN(id));
|
||||
if (productIdArray.length === 0) {
|
||||
throw new Error('At least one valid productId is required');
|
||||
}
|
||||
return this.service.getProductPricesInRegionBatch(userId, productIdArray, regionId);
|
||||
});
|
||||
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
||||
const productId = parseInt(req.query.productId, 10);
|
||||
const currentPrice = parseFloat(req.query.currentPrice);
|
||||
|
||||
@@ -25,11 +25,13 @@ function createServer() {
|
||||
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||
});
|
||||
wss = new WebSocketServer({ server: httpsServer });
|
||||
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
||||
});
|
||||
} else {
|
||||
wss = new WebSocketServer({ port: PORT });
|
||||
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||
wss = new WebSocketServer({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Rollback: Remove indexes for director proposals and character queries
|
||||
-- Created: 2026-01-12
|
||||
|
||||
DROP INDEX IF EXISTS falukant_data.idx_character_region_user_created;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_character_region_user;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_character_user_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_director_proposal_employer_character;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_director_character_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_director_employer_user_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_knowledge_character_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_relationship_character1_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_child_relation_father_id;
|
||||
DROP INDEX IF EXISTS falukant_data.idx_child_relation_mother_id;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration: Add indexes for director proposals and character queries
|
||||
-- Created: 2026-01-12
|
||||
|
||||
-- Index für schnelle Suche nach NPCs in einer Region (mit Altersbeschränkung)
|
||||
CREATE INDEX IF NOT EXISTS idx_character_region_user_created
|
||||
ON falukant_data.character (region_id, user_id, created_at)
|
||||
WHERE user_id IS NULL;
|
||||
|
||||
-- Index für schnelle Suche nach NPCs ohne Altersbeschränkung
|
||||
CREATE INDEX IF NOT EXISTS idx_character_region_user
|
||||
ON falukant_data.character (region_id, user_id)
|
||||
WHERE user_id IS NULL;
|
||||
|
||||
-- Index für Character-Suche nach user_id (wichtig für getFamily, getDirectorForBranch)
|
||||
CREATE INDEX IF NOT EXISTS idx_character_user_id
|
||||
ON falukant_data.character (user_id);
|
||||
|
||||
-- Index für Director-Proposals
|
||||
CREATE INDEX IF NOT EXISTS idx_director_proposal_employer_character
|
||||
ON falukant_data.director_proposal (employer_user_id, director_character_id);
|
||||
|
||||
-- Index für aktive Direktoren
|
||||
CREATE INDEX IF NOT EXISTS idx_director_character_id
|
||||
ON falukant_data.director (director_character_id);
|
||||
|
||||
-- Index für Director-Suche nach employer_user_id
|
||||
CREATE INDEX IF NOT EXISTS idx_director_employer_user_id
|
||||
ON falukant_data.director (employer_user_id);
|
||||
|
||||
-- Index für Knowledge-Berechnung
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_character_id
|
||||
ON falukant_data.knowledge (character_id);
|
||||
|
||||
-- Index für Relationships (getFamily)
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_character1_id
|
||||
ON falukant_data.relationship (character1_id);
|
||||
|
||||
-- Index für ChildRelations (getFamily)
|
||||
CREATE INDEX IF NOT EXISTS idx_child_relation_father_id
|
||||
ON falukant_data.child_relation (father_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_child_relation_mother_id
|
||||
ON falukant_data.child_relation (mother_id);
|
||||
@@ -76,6 +76,7 @@ router.get('/politics/open', falukantController.getOpenPolitics);
|
||||
router.post('/politics/open', falukantController.applyForElections);
|
||||
router.get('/cities', falukantController.getRegions);
|
||||
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
||||
router.get('/products/prices-in-region-batch', falukantController.getProductPricesInRegionBatch);
|
||||
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
||||
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||
|
||||
@@ -1,19 +1,55 @@
|
||||
import './config/loadEnv.js'; // .env deterministisch laden
|
||||
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import app from './app.js';
|
||||
import { setupWebSocket } from './utils/socket.js';
|
||||
import { syncDatabase } from './utils/syncDatabase.js';
|
||||
|
||||
const server = http.createServer(app);
|
||||
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy)
|
||||
const API_PORT = Number.parseInt(process.env.PORT || '2020', 10);
|
||||
const httpServer = http.createServer(app);
|
||||
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
|
||||
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS
|
||||
|
||||
setupWebSocket(server);
|
||||
// HTTPS-Server für Socket.io (Port 4443, direkt erreichbar)
|
||||
let httpsServer = null;
|
||||
const SOCKET_IO_PORT = Number.parseInt(process.env.SOCKET_IO_PORT || '4443', 10);
|
||||
const USE_TLS = process.env.SOCKET_IO_TLS === '1';
|
||||
const TLS_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
|
||||
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
|
||||
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_PATH;
|
||||
|
||||
if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
|
||||
try {
|
||||
httpsServer = https.createServer({
|
||||
key: fs.readFileSync(TLS_KEY_PATH),
|
||||
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||
}, app);
|
||||
setupWebSocket(httpsServer);
|
||||
console.log(`[Socket.io] HTTPS-Server für Socket.io konfiguriert auf Port ${SOCKET_IO_PORT}`);
|
||||
} catch (err) {
|
||||
console.error('[Socket.io] Fehler beim Laden der TLS-Zertifikate:', err.message);
|
||||
console.error('[Socket.io] Socket.io wird nicht verfügbar sein');
|
||||
}
|
||||
} else {
|
||||
console.warn('[Socket.io] TLS nicht konfiguriert - Socket.io wird nicht verfügbar sein');
|
||||
}
|
||||
|
||||
syncDatabase().then(() => {
|
||||
const port = process.env.PORT || 3001;
|
||||
server.listen(port, () => {
|
||||
console.log('Server is running on port', port);
|
||||
// API-Server auf Port 2020 (intern, nur localhost)
|
||||
httpServer.listen(API_PORT, '127.0.0.1', () => {
|
||||
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`);
|
||||
});
|
||||
|
||||
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
|
||||
if (httpsServer) {
|
||||
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => {
|
||||
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to sync database:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -33,6 +33,7 @@ import PromotionalGiftCharacterTrait from '../models/falukant/predefine/promotio
|
||||
import PromotionalGiftMood from '../models/falukant/predefine/promotional_gift_mood.js';
|
||||
import PromotionalGiftLog from '../models/falukant/log/promotional_gift.js';
|
||||
import CharacterTrait from '../models/falukant/type/character_trait.js';
|
||||
import FalukantCharacterTrait from '../models/falukant/data/falukant_character_trait.js';
|
||||
import Mood from '../models/falukant/type/mood.js';
|
||||
import UserHouse from '../models/falukant/data/user_house.js';
|
||||
import HouseType from '../models/falukant/type/house.js';
|
||||
@@ -2463,154 +2464,84 @@ class FalukantService extends BaseService {
|
||||
const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
|
||||
const proposalCount = Math.floor(Math.random() * 3) + 3;
|
||||
|
||||
// Hole bereits existierende Proposals, um diese Charaktere auszuschließen
|
||||
const existingProposals = await DirectorProposal.findAll({
|
||||
where: { employerUserId: falukantUserId },
|
||||
attributes: ['directorCharacterId'],
|
||||
raw: true
|
||||
});
|
||||
const proposalCharacterIds = existingProposals.map(p => p.directorCharacterId);
|
||||
// OPTIMIERUNG: Verwende eine einzige SQL-Query mit CTEs statt mehrerer separater Queries
|
||||
// Dies ist viel schneller, da PostgreSQL die Query optimieren kann
|
||||
// Die Knowledge-Berechnung wird direkt in SQL gemacht (AVG)
|
||||
const sqlQuery = `
|
||||
WITH excluded_characters AS (
|
||||
SELECT DISTINCT director_character_id AS id
|
||||
FROM falukant_data.director_proposal
|
||||
WHERE employer_user_id = :falukantUserId
|
||||
UNION
|
||||
SELECT DISTINCT director_character_id AS id
|
||||
FROM falukant_data.director
|
||||
),
|
||||
older_characters AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.title_of_nobility,
|
||||
t.level,
|
||||
COALESCE(AVG(k.knowledge), 0) AS avg_knowledge
|
||||
FROM falukant_data.character c
|
||||
LEFT JOIN falukant_type.title_of_nobility t ON t.id = c.title_of_nobility
|
||||
LEFT JOIN falukant_data.knowledge k ON k.character_id = c.id
|
||||
WHERE c.region_id = :regionId
|
||||
AND c.user_id IS NULL
|
||||
AND c.created_at < :threeWeeksAgo
|
||||
AND c.id NOT IN (SELECT id FROM excluded_characters)
|
||||
GROUP BY c.id, c.title_of_nobility, t.level
|
||||
ORDER BY RANDOM()
|
||||
LIMIT :proposalCount
|
||||
),
|
||||
all_characters AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.title_of_nobility,
|
||||
t.level,
|
||||
COALESCE(AVG(k.knowledge), 0) AS avg_knowledge
|
||||
FROM falukant_data.character c
|
||||
LEFT JOIN falukant_type.title_of_nobility t ON t.id = c.title_of_nobility
|
||||
LEFT JOIN falukant_data.knowledge k ON k.character_id = c.id
|
||||
WHERE c.region_id = :regionId
|
||||
AND c.user_id IS NULL
|
||||
AND c.id NOT IN (SELECT id FROM excluded_characters)
|
||||
AND c.id NOT IN (SELECT id FROM older_characters)
|
||||
GROUP BY c.id, c.title_of_nobility, t.level
|
||||
ORDER BY RANDOM()
|
||||
LIMIT GREATEST(0, :proposalCount - (SELECT COUNT(*) FROM older_characters))
|
||||
)
|
||||
SELECT * FROM older_characters
|
||||
UNION ALL
|
||||
SELECT * FROM all_characters
|
||||
LIMIT :proposalCount
|
||||
`;
|
||||
|
||||
// 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 = {
|
||||
const results = await sequelize.query(sqlQuery, {
|
||||
replacements: {
|
||||
falukantUserId,
|
||||
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'],
|
||||
threeWeeksAgo,
|
||||
proposalCount
|
||||
},
|
||||
],
|
||||
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: [
|
||||
{
|
||||
model: TitleOfNobility,
|
||||
as: 'nobleTitle',
|
||||
attributes: ['level'],
|
||||
},
|
||||
],
|
||||
order: sequelize.literal('RANDOM()'),
|
||||
limit: proposalCount,
|
||||
};
|
||||
|
||||
// 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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
// Limitiere auf proposalCount
|
||||
directorCharacters = directorCharacters.slice(0, proposalCount);
|
||||
}
|
||||
|
||||
if (directorCharacters.length === 0) {
|
||||
console.error(`[generateProposals] No NPCs found in region ${regionId} at all`);
|
||||
if (results.length === 0) {
|
||||
console.error(`[generateProposals] No NPCs found in region ${regionId}`);
|
||||
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);
|
||||
});
|
||||
console.log(`[generateProposals] Found ${results.length} available NPCs`);
|
||||
|
||||
// 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 proposalsToCreate = results.map(row => {
|
||||
const avgKnowledge = parseFloat(row.avg_knowledge) || 0;
|
||||
const proposedIncome = Math.round(
|
||||
character.nobleTitle.level * Math.pow(1.231, avgKnowledge / 1.5)
|
||||
row.level * Math.pow(1.231, avgKnowledge / 1.5)
|
||||
);
|
||||
|
||||
return {
|
||||
directorCharacterId: character.id,
|
||||
directorCharacterId: row.id,
|
||||
employerUserId: falukantUserId,
|
||||
proposedIncome,
|
||||
};
|
||||
@@ -2878,9 +2809,17 @@ class FalukantService extends BaseService {
|
||||
|
||||
return {
|
||||
id: director.id,
|
||||
character: {
|
||||
name: `${director.character.definedFirstName.name} ${director.character.definedLastName.name}`,
|
||||
title: director.character.nobleTitle.labelTr,
|
||||
age: Math.floor((Date.now() - new Date(director.character.birthdate)) / (24 * 60 * 60 * 1000)),
|
||||
gender: director.character.gender,
|
||||
nobleTitle: director.character.nobleTitle,
|
||||
definedFirstName: director.character.definedFirstName,
|
||||
definedLastName: director.character.definedLastName,
|
||||
knowledges: director.character.knowledges,
|
||||
},
|
||||
satisfaction: director.satisfaction,
|
||||
character: director.character,
|
||||
age: calcAge(director.character.birthdate),
|
||||
income: director.income,
|
||||
region: director.character.region.name,
|
||||
wishedIncome,
|
||||
@@ -2942,65 +2881,171 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
|
||||
async getFamily(hashedUserId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const startTime = Date.now();
|
||||
const timings = {};
|
||||
|
||||
try {
|
||||
// 1. User und Character laden (optimiert: nur benötigte Felder)
|
||||
const step1Start = Date.now();
|
||||
const user = await FalukantUser.findOne({
|
||||
include: [
|
||||
{ model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } },
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'character',
|
||||
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'],
|
||||
required: true
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!user) throw new Error('User not found');
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
const character = user.character;
|
||||
if (!character) throw new Error('Character not found for this user');
|
||||
let relationships = await Relationship.findAll({
|
||||
timings.step1_user_character = Date.now() - step1Start;
|
||||
|
||||
// 2. Relationships und Children parallel laden
|
||||
const step2Start = Date.now();
|
||||
const [relationshipsRaw, charsWithChildren] = await Promise.all([
|
||||
Relationship.findAll({
|
||||
where: { character1Id: character.id },
|
||||
attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress'],
|
||||
attributes: ['createdAt', 'widowFirstName2', 'nextStepProgress', 'relationshipTypeId'],
|
||||
include: [
|
||||
{
|
||||
model: FalukantCharacter, as: 'character2',
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId'],
|
||||
include: [
|
||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] },
|
||||
{ model: CharacterTrait, as: 'traits' },
|
||||
{ model: Mood, as: 'mood' },
|
||||
]
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'firstName', 'lastName', 'titleOfNobility'],
|
||||
required: false
|
||||
},
|
||||
{ model: RelationshipType, as: 'relationshipType', attributes: ['tr'] }
|
||||
{ model: RelationshipType, as: 'relationshipType', attributes: ['tr'], required: false }
|
||||
]
|
||||
});
|
||||
relationships = relationships.map(r => ({
|
||||
createdAt: r.createdAt,
|
||||
widowFirstName2: r.widowFirstName2,
|
||||
progress: r.nextStepProgress,
|
||||
character2: {
|
||||
id: r.character2.id,
|
||||
age: calcAge(r.character2.birthdate),
|
||||
gender: r.character2.gender,
|
||||
firstName: r.character2.definedFirstName?.name || 'Unknown',
|
||||
nobleTitle: r.character2.nobleTitle?.labelTr || '',
|
||||
mood: r.character2.mood,
|
||||
traits: r.character2.traits
|
||||
},
|
||||
relationshipType: r.relationshipType.tr
|
||||
}));
|
||||
const charsWithChildren = await FalukantCharacter.findAll({
|
||||
}),
|
||||
FalukantCharacter.findAll({
|
||||
where: { userId: user.id },
|
||||
attributes: ['id'],
|
||||
include: [
|
||||
{
|
||||
model: ChildRelation,
|
||||
as: 'childrenFather',
|
||||
attributes: ['nameSet', 'isHeir', 'createdAt', 'childCharacterId'],
|
||||
include: [{
|
||||
model: FalukantCharacter,
|
||||
as: 'child',
|
||||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||||
}]
|
||||
attributes: ['id', 'birthdate', 'gender', 'firstName'],
|
||||
required: false
|
||||
}],
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: ChildRelation,
|
||||
as: 'childrenMother',
|
||||
attributes: ['nameSet', 'isHeir', 'createdAt', 'childCharacterId'],
|
||||
include: [{
|
||||
model: FalukantCharacter,
|
||||
as: 'child',
|
||||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||||
}]
|
||||
attributes: ['id', 'birthdate', 'gender', 'firstName'],
|
||||
required: false
|
||||
}],
|
||||
required: false
|
||||
}
|
||||
]
|
||||
})
|
||||
]);
|
||||
timings.step2_relationships_children = Date.now() - step2Start;
|
||||
|
||||
// 3. Batch-Loading für Relationship-Character-Daten
|
||||
const step3Start = Date.now();
|
||||
const relationshipCharacters = relationshipsRaw
|
||||
.filter(r => r.character2)
|
||||
.map(r => r.character2);
|
||||
const relationshipCharacterIds = relationshipCharacters.map(c => c.id);
|
||||
const childCharacters = charsWithChildren
|
||||
.flatMap(c => [
|
||||
...(c.childrenFather || []).map(r => r.child).filter(Boolean),
|
||||
...(c.childrenMother || []).map(r => r.child).filter(Boolean)
|
||||
]);
|
||||
const childCharacterIds = childCharacters.map(c => c.id);
|
||||
|
||||
// Sammle alle benötigten IDs
|
||||
const relationshipFirstNameIds = [...new Set(relationshipCharacters.map(c => c.firstName).filter(Boolean))];
|
||||
const relationshipLastNameIds = [...new Set(relationshipCharacters.map(c => c.lastName).filter(Boolean))];
|
||||
const relationshipTitleIds = [...new Set(relationshipCharacters.map(c => c.titleOfNobility).filter(Boolean))];
|
||||
const relationshipMoodIds = [...new Set(relationshipCharacters.map(c => c.moodId).filter(Boolean))];
|
||||
const childFirstNameIds = [...new Set(childCharacters.map(c => c.firstName).filter(Boolean))];
|
||||
const allFirstNameIds = [...new Set([...relationshipFirstNameIds, ...childFirstNameIds])];
|
||||
|
||||
// Batch-Load alle benötigten Daten parallel
|
||||
const [firstNames, lastNames, titles, traitRelations, moods] = await Promise.all([
|
||||
allFirstNameIds.length > 0 ? FalukantPredefineFirstname.findAll({
|
||||
where: { id: { [Op.in]: allFirstNameIds } },
|
||||
attributes: ['id', 'name']
|
||||
}) : [],
|
||||
relationshipLastNameIds.length > 0 ? FalukantPredefineLastname.findAll({
|
||||
where: { id: { [Op.in]: relationshipLastNameIds } },
|
||||
attributes: ['id', 'name']
|
||||
}) : [],
|
||||
relationshipTitleIds.length > 0 ? TitleOfNobility.findAll({
|
||||
where: { id: { [Op.in]: relationshipTitleIds } },
|
||||
attributes: ['id', 'labelTr']
|
||||
}) : [],
|
||||
relationshipCharacterIds.length > 0 ? FalukantCharacterTrait.findAll({
|
||||
where: { characterId: { [Op.in]: relationshipCharacterIds } },
|
||||
attributes: ['characterId', 'traitId']
|
||||
}) : [],
|
||||
relationshipMoodIds.length > 0 ? Mood.findAll({
|
||||
where: { id: { [Op.in]: relationshipMoodIds } },
|
||||
attributes: ['id', 'tr']
|
||||
}) : []
|
||||
]);
|
||||
|
||||
// Sammle alle eindeutigen Trait-IDs und lade die Trait-Types
|
||||
const allTraitIds = [...new Set(traitRelations.map(t => t.traitId))];
|
||||
const traitTypes = allTraitIds.length > 0 ? await CharacterTrait.findAll({
|
||||
where: { id: { [Op.in]: allTraitIds } },
|
||||
attributes: ['id', 'tr']
|
||||
}) : [];
|
||||
|
||||
// Erstelle Maps für schnellen Zugriff
|
||||
const firstNameMap = new Map(firstNames.map(fn => [fn.id, fn.name]));
|
||||
const lastNameMap = new Map(lastNames.map(ln => [ln.id, ln.name]));
|
||||
const titleMap = new Map(titles.map(t => [t.id, t.labelTr]));
|
||||
const moodMap = new Map(moods.map(m => [m.id, m.tr]));
|
||||
const traitTypeMap = new Map(traitTypes.map(t => [t.id, { id: t.id, tr: t.tr }]));
|
||||
const traitsMap = new Map();
|
||||
traitRelations.forEach(t => {
|
||||
if (!traitsMap.has(t.characterId)) {
|
||||
traitsMap.set(t.characterId, []);
|
||||
}
|
||||
const traitObj = traitTypeMap.get(t.traitId);
|
||||
if (traitObj) {
|
||||
traitsMap.get(t.characterId).push(traitObj);
|
||||
}
|
||||
});
|
||||
timings.step3_batch_loading = Date.now() - step3Start;
|
||||
|
||||
// 4. Relationships mappen
|
||||
const step4Start = Date.now();
|
||||
const relationships = relationshipsRaw.map(r => {
|
||||
const char2 = r.character2;
|
||||
return {
|
||||
createdAt: r.createdAt,
|
||||
widowFirstName2: r.widowFirstName2,
|
||||
progress: r.nextStepProgress,
|
||||
character2: char2 ? {
|
||||
id: char2.id,
|
||||
age: calcAge(char2.birthdate),
|
||||
gender: char2.gender,
|
||||
firstName: firstNameMap.get(char2.firstName) || 'Unknown',
|
||||
nobleTitle: titleMap.get(char2.titleOfNobility) || '',
|
||||
mood: moodMap.get(char2.moodId) || null,
|
||||
moodId: char2.moodId,
|
||||
traits: traitsMap.get(char2.id) || []
|
||||
} : null,
|
||||
relationshipType: r.relationshipType?.tr || ''
|
||||
};
|
||||
}).filter(r => r.character2 !== null);
|
||||
timings.step4_map_relationships = Date.now() - step4Start;
|
||||
|
||||
// 5. Children mappen
|
||||
const step5Start = Date.now();
|
||||
const children = [];
|
||||
for (const parentChar of charsWithChildren) {
|
||||
const allRels = [
|
||||
@@ -3009,9 +3054,10 @@ class FalukantService extends BaseService {
|
||||
];
|
||||
for (const rel of allRels) {
|
||||
const kid = rel.child;
|
||||
if (!kid) continue;
|
||||
children.push({
|
||||
childCharacterId: kid.id,
|
||||
name: kid.definedFirstName?.name || 'Unknown',
|
||||
name: firstNameMap.get(kid.firstName) || 'Unknown',
|
||||
gender: kid.gender,
|
||||
age: calcAge(kid.birthdate),
|
||||
hasName: rel.nameSet,
|
||||
@@ -3020,8 +3066,11 @@ class FalukantService extends BaseService {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort children globally by relation createdAt ascending (older first)
|
||||
children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt));
|
||||
timings.step5_map_children = Date.now() - step5Start;
|
||||
|
||||
// 6. Family-Objekt erstellen
|
||||
const step6Start = Date.now();
|
||||
const inProgress = ['wooing', 'engaged', 'married'];
|
||||
const family = {
|
||||
relationships: relationships.filter(r => inProgress.includes(r.relationshipType)),
|
||||
@@ -3030,21 +3079,31 @@ class FalukantService extends BaseService {
|
||||
children: children.map(({ _createdAt, ...rest }) => rest),
|
||||
possiblePartners: []
|
||||
};
|
||||
timings.step6_create_family = Date.now() - step6Start;
|
||||
|
||||
// 7. Possible Partners (nur wenn nötig, asynchron)
|
||||
const step7Start = Date.now();
|
||||
const ownAge = calcAge(character.birthdate);
|
||||
if (ownAge >= 12 && family.relationships.length === 0) {
|
||||
family.possiblePartners = await this.getPossiblePartners(character.id);
|
||||
if (family.possiblePartners.length === 0) {
|
||||
await this.createPossiblePartners(
|
||||
// Asynchron erstellen, nicht blockieren
|
||||
this.createPossiblePartners(
|
||||
character.id,
|
||||
character.gender,
|
||||
character.regionId,
|
||||
character.titleOfNobility,
|
||||
ownAge
|
||||
);
|
||||
family.possiblePartners = await this.getPossiblePartners(character.id);
|
||||
).catch(err => console.error('[getFamily] Error creating partners (async):', err));
|
||||
}
|
||||
}
|
||||
timings.step7_possible_partners = Date.now() - step7Start;
|
||||
|
||||
return family;
|
||||
} catch (error) {
|
||||
console.error('[getFamily] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setHeir(hashedUserId, childCharacterId) {
|
||||
@@ -3313,20 +3372,29 @@ class FalukantService extends BaseService {
|
||||
ownAge
|
||||
});
|
||||
|
||||
const minAgeDate = new Date(new Date() - 12 * 24 * 60 * 60 * 1000);
|
||||
const titleMin = Math.max(1, requestingCharacterTitleOfNobility - 1);
|
||||
const titleMax = requestingCharacterTitleOfNobility + 1;
|
||||
|
||||
console.log(`[createPossiblePartners] Search criteria:`, {
|
||||
excludeId: requestingCharacterId,
|
||||
gender: `not ${requestingCharacterGender}`,
|
||||
regionId: requestingRegionId,
|
||||
minAge: '12 days old',
|
||||
titleRange: `${titleMin}-${titleMax}`,
|
||||
userId: 'null (NPCs only)'
|
||||
});
|
||||
|
||||
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] }
|
||||
createdAt: { [Op.lt]: minAgeDate },
|
||||
titleOfNobility: { [Op.between]: [titleMin, titleMax] },
|
||||
userId: null // Nur NPCs suchen
|
||||
};
|
||||
|
||||
// Nur NPCs suchen (userId ist null)
|
||||
whereClause.userId = null;
|
||||
|
||||
console.log(`[createPossiblePartners] Where clause:`, JSON.stringify(whereClause, null, 2));
|
||||
|
||||
const potentialPartners = await FalukantCharacter.findAll({
|
||||
let potentialPartners = await FalukantCharacter.findAll({
|
||||
where: whereClause,
|
||||
order: [
|
||||
[Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC']
|
||||
@@ -3334,12 +3402,55 @@ class FalukantService extends BaseService {
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners`);
|
||||
console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners in region ${requestingRegionId}`);
|
||||
|
||||
// Fallback: Wenn keine Partner in der gleichen Region gefunden werden, suche in allen Regionen
|
||||
if (potentialPartners.length === 0) {
|
||||
console.log(`[createPossiblePartners] No partners in region ${requestingRegionId}, trying all regions...`);
|
||||
const fallbackWhereClause = {
|
||||
id: { [Op.ne]: requestingCharacterId },
|
||||
gender: { [Op.ne]: requestingCharacterGender },
|
||||
createdAt: { [Op.lt]: minAgeDate },
|
||||
titleOfNobility: { [Op.between]: [titleMin, titleMax] },
|
||||
userId: null
|
||||
};
|
||||
|
||||
potentialPartners = await FalukantCharacter.findAll({
|
||||
where: fallbackWhereClause,
|
||||
order: [
|
||||
[Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC']
|
||||
],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
console.log(`[createPossiblePartners] Found ${potentialPartners.length} potential partners in all regions`);
|
||||
}
|
||||
|
||||
if (potentialPartners.length === 0) {
|
||||
console.warn(`[createPossiblePartners] No partners found with criteria. Consider creating NPCs.`);
|
||||
console.log(`[createPossiblePartners] No partners found, creating new NPCs...`);
|
||||
// Erstelle automatisch 5 neue NPCs, die den Kriterien entsprechen
|
||||
const targetGender = requestingCharacterGender === 'male' ? 'female' : 'male';
|
||||
const createdNPCs = await this._createNPCsForMarriage(
|
||||
requestingRegionId,
|
||||
targetGender,
|
||||
titleMin,
|
||||
titleMax,
|
||||
ownAge,
|
||||
5
|
||||
);
|
||||
|
||||
if (createdNPCs.length > 0) {
|
||||
console.log(`[createPossiblePartners] Created ${createdNPCs.length} new NPCs, using them as partners`);
|
||||
potentialPartners = createdNPCs;
|
||||
} else {
|
||||
console.warn(`[createPossiblePartners] Failed to create NPCs. Consider creating NPCs manually with:`);
|
||||
console.warn(` - gender: ${targetGender}`);
|
||||
console.warn(` - regionId: ${requestingRegionId}`);
|
||||
console.warn(` - titleOfNobility: ${titleMin}-${titleMax}`);
|
||||
console.warn(` - age: ~${ownAge} years`);
|
||||
return; // Keine Partner gefunden, aber kein Fehler
|
||||
}
|
||||
}
|
||||
|
||||
const proposals = potentialPartners.map(partner => {
|
||||
const age = calcAge(partner.birthdate);
|
||||
@@ -3358,6 +3469,74 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async _createNPCsForMarriage(regionId, gender, minTitle, maxTitle, targetAge, count = 5) {
|
||||
try {
|
||||
const sequelize = FalukantCharacter.sequelize;
|
||||
const createdNPCs = [];
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Zufälliger Titel im Bereich
|
||||
const randomTitle = Math.floor(Math.random() * (maxTitle - minTitle + 1)) + minTitle;
|
||||
|
||||
// Alter: ±2 Jahre um targetAge
|
||||
const ageVariation = Math.floor(Math.random() * 5) - 2; // -2 bis +2
|
||||
const randomAge = Math.max(12, targetAge + ageVariation); // Mindestens 12 Jahre
|
||||
|
||||
// Zufälliger Vorname für das Geschlecht
|
||||
const firstName = await FalukantPredefineFirstname.findAll({
|
||||
where: { gender },
|
||||
order: sequelize.fn('RANDOM'),
|
||||
limit: 1,
|
||||
transaction: t
|
||||
});
|
||||
|
||||
if (!firstName || firstName.length === 0) {
|
||||
console.warn(`[_createNPCsForMarriage] No first names found for gender ${gender}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zufälliger Nachname
|
||||
const lastName = await FalukantPredefineLastname.findAll({
|
||||
order: sequelize.fn('RANDOM'),
|
||||
limit: 1,
|
||||
transaction: t
|
||||
});
|
||||
|
||||
if (!lastName || lastName.length === 0) {
|
||||
console.warn(`[_createNPCsForMarriage] No last names found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Geburtsdatum berechnen (Alter in Tagen)
|
||||
const birthdate = new Date();
|
||||
birthdate.setDate(birthdate.getDate() - randomAge);
|
||||
|
||||
// Erstelle den NPC-Charakter
|
||||
const npc = await FalukantCharacter.create({
|
||||
userId: null, // Wichtig: null = NPC
|
||||
regionId: regionId,
|
||||
firstName: firstName[0].id,
|
||||
lastName: lastName[0].id,
|
||||
gender: gender,
|
||||
birthdate: birthdate,
|
||||
titleOfNobility: randomTitle,
|
||||
health: 100,
|
||||
moodId: 1
|
||||
}, { transaction: t });
|
||||
|
||||
createdNPCs.push(npc);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[_createNPCsForMarriage] Created ${createdNPCs.length} NPCs`);
|
||||
return createdNPCs;
|
||||
} catch (error) {
|
||||
console.error('[_createNPCsForMarriage] Error creating NPCs:', error);
|
||||
return []; // Bei Fehler leeres Array zurückgeben
|
||||
}
|
||||
}
|
||||
|
||||
async acceptMarriageProposal(hashedUserId, proposedCharacterId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
@@ -3399,37 +3578,72 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
|
||||
async getGifts(hashedUserId) {
|
||||
// 1) Mein User & Character
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
if (!myChar) throw new Error('Character not found');
|
||||
const startTime = Date.now();
|
||||
const timings = {};
|
||||
|
||||
// 2) Beziehung finden und „anderen" Character bestimmen
|
||||
const rel = await Relationship.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ character1Id: myChar.id },
|
||||
{ character2Id: myChar.id }
|
||||
]
|
||||
},
|
||||
try {
|
||||
// 1) User & Character optimiert laden (nur benötigte Felder)
|
||||
const step1Start = Date.now();
|
||||
const user = await FalukantUser.findOne({
|
||||
include: [
|
||||
{ model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] },
|
||||
{ model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] }
|
||||
{ model: User, as: 'user', attributes: ['hashedId'], where: { hashedId: hashedUserId } },
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'character',
|
||||
attributes: ['id', 'titleOfNobility'],
|
||||
required: true
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!user) throw new Error('User not found');
|
||||
const myChar = user.character;
|
||||
if (!myChar) throw new Error('Character not found');
|
||||
timings.step1_user_character = Date.now() - step1Start;
|
||||
|
||||
// 3) Wenn keine Beziehung gefunden, alle Gifts ohne Filter zurückgeben
|
||||
// 2) Beziehung finden (zwei separate Queries für bessere Index-Nutzung)
|
||||
const step2Start = Date.now();
|
||||
const [relAsChar1, relAsChar2] = await Promise.all([
|
||||
Relationship.findOne({
|
||||
where: { character1Id: myChar.id },
|
||||
attributes: ['character1Id', 'character2Id']
|
||||
}),
|
||||
Relationship.findOne({
|
||||
where: { character2Id: myChar.id },
|
||||
attributes: ['character1Id', 'character2Id']
|
||||
})
|
||||
]);
|
||||
const rel = relAsChar1 || relAsChar2;
|
||||
timings.step2_relationship = Date.now() - step2Start;
|
||||
|
||||
// 3) Related Character und Traits laden (nur wenn Relationship existiert)
|
||||
const step3Start = Date.now();
|
||||
let relatedTraitIds = [];
|
||||
let relatedMoodId = null;
|
||||
|
||||
if (rel) {
|
||||
const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1;
|
||||
// Trait-IDs und Mood des relatedChar
|
||||
relatedTraitIds = relatedChar.traits ? relatedChar.traits.map(t => t.id) : [];
|
||||
const relatedCharId = rel.character1Id === myChar.id ? rel.character2Id : rel.character1Id;
|
||||
|
||||
// Parallel: Character (moodId) und Traits laden
|
||||
const [relatedChar, traitRows] = await Promise.all([
|
||||
FalukantCharacter.findOne({
|
||||
where: { id: relatedCharId },
|
||||
attributes: ['id', 'moodId']
|
||||
}),
|
||||
FalukantCharacterTrait.findAll({
|
||||
where: { characterId: relatedCharId },
|
||||
attributes: ['traitId']
|
||||
})
|
||||
]);
|
||||
|
||||
if (relatedChar) {
|
||||
relatedMoodId = relatedChar.moodId;
|
||||
relatedTraitIds = traitRows.map(t => t.traitId);
|
||||
}
|
||||
}
|
||||
timings.step3_load_character_and_traits = Date.now() - step3Start;
|
||||
|
||||
// 4) Gifts laden – mit Mood/Trait-Filter nur wenn Beziehung existiert
|
||||
const step4Start = Date.now();
|
||||
const giftIncludes = [
|
||||
{
|
||||
model: PromotionalGiftMood,
|
||||
@@ -3450,26 +3664,43 @@ class FalukantService extends BaseService {
|
||||
giftIncludes[0].where = { mood_id: relatedMoodId };
|
||||
}
|
||||
if (rel && relatedTraitIds.length > 0) {
|
||||
giftIncludes[1].where = { trait_id: relatedTraitIds };
|
||||
giftIncludes[1].where = { trait_id: { [Op.in]: relatedTraitIds } };
|
||||
}
|
||||
timings.step4_prepare_gift_includes = Date.now() - step4Start;
|
||||
|
||||
const gifts = await PromotionalGift.findAll({
|
||||
// 5) Parallel: Gifts und lowestTitleOfNobility laden
|
||||
const step5Start = Date.now();
|
||||
const [gifts, lowestTitleOfNobility] = await Promise.all([
|
||||
PromotionalGift.findAll({
|
||||
include: giftIncludes
|
||||
});
|
||||
}),
|
||||
TitleOfNobility.findOne({ order: [['id', 'ASC']] })
|
||||
]);
|
||||
timings.step5_load_gifts_and_title = Date.now() - step5Start;
|
||||
|
||||
// 5) Rest wie gehabt: Kosten berechnen und zurückgeben
|
||||
const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] });
|
||||
return Promise.all(gifts.map(async gift => ({
|
||||
// 6) Kosten berechnen (getGiftCost ist synchron)
|
||||
const step6Start = Date.now();
|
||||
const result = gifts.map(gift => ({
|
||||
id: gift.id,
|
||||
name: gift.name,
|
||||
cost: await this.getGiftCost(
|
||||
cost: this.getGiftCost(
|
||||
gift.value,
|
||||
myChar.titleOfNobility,
|
||||
lowestTitleOfNobility.id
|
||||
),
|
||||
moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId
|
||||
charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds
|
||||
})));
|
||||
moodsAffects: gift.promotionalgiftmoods || [], // nur Einträge mit relatedMoodId (wenn Filter angewendet)
|
||||
charactersAffects: gift.characterTraits || [] // nur Einträge mit relatedTraitIds (wenn Filter angewendet)
|
||||
}));
|
||||
timings.step6_calculate_costs = Date.now() - step6Start;
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
console.log(`[getGifts] Performance: ${totalTime}ms total`, timings);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[getGifts] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getChildren(hashedUserId) {
|
||||
@@ -3567,7 +3798,7 @@ class FalukantService extends BaseService {
|
||||
if (!gift) {
|
||||
throw new Error('notFound');
|
||||
}
|
||||
const cost = await this.getGiftCost(
|
||||
const cost = this.getGiftCost(
|
||||
gift.value,
|
||||
user.character.nobleTitle.id,
|
||||
lowestTitle.id
|
||||
@@ -3623,7 +3854,7 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) {
|
||||
getGiftCost(value, titleOfNobility, lowestTitleOfNobility) {
|
||||
const titleLevel = titleOfNobility - lowestTitleOfNobility + 1;
|
||||
return Math.round(value * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100;
|
||||
}
|
||||
@@ -5069,6 +5300,57 @@ class FalukantService extends BaseService {
|
||||
return regions;
|
||||
}
|
||||
|
||||
async getProductPricesInRegionBatch(hashedUserId, productIds, regionId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
if (!character) {
|
||||
throw new Error(`No FalukantCharacter found for user with id ${user.id}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(productIds) || productIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Hole alle Produkte auf einmal
|
||||
const products = await ProductType.findAll({
|
||||
where: { id: { [Op.in]: productIds } }
|
||||
});
|
||||
|
||||
// Hole alle Knowledge-Werte auf einmal
|
||||
const knowledges = await Knowledge.findAll({
|
||||
where: {
|
||||
characterId: character.id,
|
||||
productId: { [Op.in]: productIds }
|
||||
}
|
||||
});
|
||||
const knowledgeMap = new Map(knowledges.map(k => [k.productId, k.knowledge]));
|
||||
|
||||
// Hole alle TownProductWorth-Werte auf einmal
|
||||
const townWorths = await TownProductWorth.findAll({
|
||||
where: {
|
||||
productId: { [Op.in]: productIds },
|
||||
regionId: regionId
|
||||
}
|
||||
});
|
||||
const worthMap = new Map(townWorths.map(tw => [tw.productId, tw.worthPercent]));
|
||||
|
||||
// Berechne Preise für alle Produkte
|
||||
const prices = {};
|
||||
for (const product of products) {
|
||||
const knowledgeFactor = knowledgeMap.get(product.id) || 0;
|
||||
const worthPercent = worthMap.get(product.id) || 50;
|
||||
|
||||
const basePrice = product.sellCost * (worthPercent / 100);
|
||||
const min = basePrice * 0.6;
|
||||
const max = basePrice;
|
||||
const price = min + (max - min) * (knowledgeFactor / 100);
|
||||
|
||||
prices[product.id] = Math.round(price * 100) / 100; // Auf 2 Dezimalstellen runden
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
async getProductPriceInRegion(hashedUserId, productId, regionId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||
|
||||
45
check-apache-websocket.sh
Executable file
45
check-apache-websocket.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== Apache WebSocket-Konfiguration prüfen ==="
|
||||
echo ""
|
||||
|
||||
# Prüfe, welche Module aktiviert sind
|
||||
echo "Aktivierte Apache-Module:"
|
||||
apache2ctl -M 2>/dev/null | grep -E "(proxy|rewrite|ssl|headers)" || echo "Keine relevanten Module gefunden"
|
||||
echo ""
|
||||
|
||||
# Prüfe, ob die benötigten Module aktiviert sind
|
||||
REQUIRED_MODULES=("proxy" "proxy_http" "proxy_wstunnel" "rewrite" "ssl" "headers")
|
||||
MISSING_MODULES=()
|
||||
|
||||
for module in "${REQUIRED_MODULES[@]}"; do
|
||||
if ! apache2ctl -M 2>/dev/null | grep -q "${module}_module"; then
|
||||
MISSING_MODULES+=("$module")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_MODULES[@]} -eq 0 ]; then
|
||||
echo "✅ Alle benötigten Module sind aktiviert"
|
||||
else
|
||||
echo "❌ Fehlende Module:"
|
||||
for module in "${MISSING_MODULES[@]}"; do
|
||||
echo " - $module"
|
||||
done
|
||||
echo ""
|
||||
echo "Aktivieren mit:"
|
||||
for module in "${MISSING_MODULES[@]}"; do
|
||||
echo " sudo a2enmod $module"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Apache-Konfiguration testen ==="
|
||||
if sudo apache2ctl configtest 2>&1; then
|
||||
echo "✅ Apache-Konfiguration ist gültig"
|
||||
else
|
||||
echo "❌ Apache-Konfiguration hat Fehler"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Aktive VirtualHosts ==="
|
||||
apache2ctl -S 2>/dev/null | grep -E "(443|4443|4551)" || echo "Keine relevanten VirtualHosts gefunden"
|
||||
35
debug-websocket-headers.sh
Executable file
35
debug-websocket-headers.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== WebSocket-Header Debug ==="
|
||||
echo ""
|
||||
echo "Prüfe Apache-Logs für WebSocket-Upgrade-Header..."
|
||||
echo ""
|
||||
|
||||
# Prüfe die letzten 50 Zeilen des Access-Logs für /ws/ oder /socket.io/
|
||||
echo "Access-Log Einträge für /ws/ und /socket.io/:"
|
||||
sudo tail -50 /var/log/apache2/yourpart.access.log | grep -E "(/ws/|/socket.io/)" | tail -10
|
||||
|
||||
echo ""
|
||||
echo "Prüfe Error-Log für WebSocket-Fehler:"
|
||||
sudo tail -50 /var/log/apache2/yourpart.error.log | grep -iE "(websocket|upgrade|proxy)" | tail -10
|
||||
|
||||
echo ""
|
||||
echo "=== Test mit curl ==="
|
||||
echo ""
|
||||
echo "Teste WebSocket-Upgrade für /ws/:"
|
||||
curl -i -N \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
-H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
|
||||
https://www.your-part.de/ws/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "=== Prüfe Apache-Konfiguration ==="
|
||||
echo ""
|
||||
echo "Aktive Rewrite-Regeln für WebSocket:"
|
||||
sudo apache2ctl -S 2>/dev/null | grep -A 5 "your-part.de:443" || echo "VirtualHost nicht gefunden"
|
||||
|
||||
echo ""
|
||||
echo "Prüfe, ob mod_proxy_wstunnel aktiviert ist:"
|
||||
apache2ctl -M 2>/dev/null | grep proxy_wstunnel || echo "mod_proxy_wstunnel NICHT aktiviert!"
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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**
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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
|
||||
@@ -1,159 +0,0 @@
|
||||
# 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
|
||||
@@ -1,334 +0,0 @@
|
||||
# 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,7 +21,6 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
@@ -2835,12 +2834,6 @@
|
||||
"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": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,225 +0,0 @@
|
||||
<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>
|
||||
@@ -151,6 +151,19 @@ export default {
|
||||
|
||||
// Extrahiere Parameter aus value und effects
|
||||
params = this.extractParamsFromValue(value, n);
|
||||
|
||||
// Wenn value eine einfache Zahl ist (z.B. für overproduction), als value-Parameter verwenden
|
||||
if (typeof parsed.value === 'number') {
|
||||
params.value = parsed.value;
|
||||
}
|
||||
|
||||
// Weitere Parameter aus parsed extrahieren (z.B. branch_id)
|
||||
if (parsed.branch_id !== undefined) {
|
||||
params.branch_id = parsed.branch_id;
|
||||
}
|
||||
if (parsed.region_id !== undefined) {
|
||||
params.region_id = parsed.region_id;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Bei Parse-Fehler: Alte Struktur unterstützen
|
||||
@@ -172,9 +185,12 @@ export default {
|
||||
if (value && value.title && value.description) {
|
||||
// Parameter aus effects extrahieren und formatieren
|
||||
const formattedParams = this.formatParams(params);
|
||||
// Zuerst Description interpolieren (für {amount} etc.), dann Effects hinzufügen
|
||||
let description = this.interpolateString(value.description, formattedParams);
|
||||
description = this.formatDescriptionWithEffects(description, value.effects || [], formattedParams);
|
||||
return {
|
||||
title: this.interpolateString(value.title, formattedParams),
|
||||
description: this.formatDescriptionWithEffects(value.description, value.effects || [], formattedParams)
|
||||
description: description
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +228,10 @@ export default {
|
||||
const title = this.$t(titleKey, formattedParams);
|
||||
let description = this.$t(descKey, formattedParams);
|
||||
|
||||
// Stelle sicher, dass auch hier die Parameter interpoliert werden (für {amount} etc.)
|
||||
// Vue i18n interpoliert bereits, aber wir müssen sicherstellen, dass formatParams korrekt formatiert
|
||||
description = this.interpolateString(description, formattedParams);
|
||||
|
||||
// Füge Effect-Details hinzu, falls vorhanden
|
||||
if (value && value.effects) {
|
||||
description = this.formatDescriptionWithEffects(description, value.effects, formattedParams);
|
||||
@@ -234,15 +254,27 @@ export default {
|
||||
|
||||
// Geldbeträge formatieren
|
||||
if (params.amount !== undefined && params.amount !== null) {
|
||||
formatted.amount = this.formatMoney(params.amount);
|
||||
formatted.amount = this.formatMoney(Number(params.amount));
|
||||
}
|
||||
if (params.absolute !== undefined && params.absolute !== null) {
|
||||
formatted.amount = this.formatMoney(params.absolute);
|
||||
formatted.amount = this.formatMoney(Number(params.absolute));
|
||||
}
|
||||
if (params.percent !== undefined && params.percent !== null) {
|
||||
formatted.percent = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// Einfache Werte (z.B. für overproduction)
|
||||
if (params.value !== undefined && params.value !== null) {
|
||||
formatted.value = Number(params.value);
|
||||
}
|
||||
|
||||
// Filiale-Information
|
||||
if (params.branch_id !== undefined && params.branch_id !== null) {
|
||||
formatted.branch_info = ` (Filiale #${params.branch_id})`;
|
||||
} else {
|
||||
formatted.branch_info = '';
|
||||
}
|
||||
|
||||
// Gesundheit formatieren
|
||||
if (params.change !== undefined && params.change !== null) {
|
||||
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
<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>
|
||||
@@ -33,7 +33,7 @@
|
||||
"notifications": {
|
||||
"notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.",
|
||||
"production": {
|
||||
"overproduction": "Überproduktion: Deine Produktion liegt {value}% über dem Bedarf."
|
||||
"overproduction": "Überproduktion: Deine Produktion liegt {value} Einheiten über dem Bedarf{branch_info}."
|
||||
},
|
||||
"transport": {
|
||||
"waiting": "Transport wartet"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"notifications": {
|
||||
"notify_election_created": "A new election has been scheduled.",
|
||||
"production": {
|
||||
"overproduction": "Overproduction: your production is {value}% above demand."
|
||||
"overproduction": "Overproduction: your production is {value} units above demand{branch_info}."
|
||||
},
|
||||
"transport": {
|
||||
"waiting": "Transport waiting"
|
||||
|
||||
@@ -188,18 +188,29 @@ const store = createStore({
|
||||
socketIoUrl = 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Normalisiere URL (Env-Variablen enthalten teils Pfade wie /api; Port kann absichtlich gesetzt sein, z.B. :4443)
|
||||
// Direkte Verbindung zu Socket.io (ohne Apache-Proxy)
|
||||
// In Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||
const hostname = window.location.hostname;
|
||||
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
|
||||
|
||||
if (isProduction) {
|
||||
// Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||||
socketIoUrl = `${protocol}//${hostname}:4443`;
|
||||
} else {
|
||||
// Lokale Entwicklung: direkte Backend-Verbindung
|
||||
if (!socketIoUrl && (import.meta.env.DEV || hostname === 'localhost' || hostname === '127.0.0.1')) {
|
||||
socketIoUrl = 'http://localhost:3001';
|
||||
} else if (socketIoUrl) {
|
||||
try {
|
||||
if (socketIoUrl) {
|
||||
const parsed = new URL(socketIoUrl, window.location.origin);
|
||||
// Falls /api oder ähnliche Pfade enthalten sind → auf Origin reduzieren (inkl. Port!)
|
||||
socketIoUrl = parsed.origin;
|
||||
}
|
||||
} catch (e) {
|
||||
// Wenn Parsing fehlschlägt: letzte Rettung ist der aktuelle Origin
|
||||
try {
|
||||
socketIoUrl = window.location.origin;
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
socketIoUrl = window.location.origin;
|
||||
}
|
||||
}
|
||||
|
||||
const socket = io(socketIoUrl, {
|
||||
@@ -284,12 +295,13 @@ const store = createStore({
|
||||
|
||||
// Wenn Umgebungsvariable nicht gesetzt ist oder leer, verwende Fallback-Logik
|
||||
if (!daemonUrl || (typeof daemonUrl === 'string' && daemonUrl.trim() === '')) {
|
||||
// Fallback: direkte Verbindung zum Daemon-Port 4551 (ohne Apache-Proxy)
|
||||
// Immer direkte Verbindung zum Daemon-Port 4551 (verschlüsselt)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
daemonUrl = `${protocol}//${hostname}:4551/`;
|
||||
console.log('[Daemon] Verwende Fallback basierend auf Hostname, Protokoll und Port 4551');
|
||||
console.log('[Daemon] Verwende direkte Verbindung zu Port 4551');
|
||||
} else {
|
||||
console.log('[Daemon] Verwende Umgebungsvariable');
|
||||
// Wenn Umgebungsvariable gesetzt ist, verwende sie direkt
|
||||
console.log('[Daemon] Verwende Umgebungsvariable:', daemonUrl);
|
||||
}
|
||||
|
||||
console.log('[Daemon] Finale Daemon-URL:', daemonUrl);
|
||||
|
||||
@@ -468,14 +468,10 @@ export default {
|
||||
},
|
||||
|
||||
watch: {
|
||||
activeTab(newVal, oldVal) {
|
||||
// Nur neu laden, wenn der Tab wirklich gewechselt wurde und ein Branch ausgewählt ist
|
||||
if (!this.selectedBranch || newVal === oldVal) return;
|
||||
|
||||
// Alle Tabs neu laden, wenn gewechselt wird
|
||||
this.$nextTick(() => {
|
||||
this.refreshActiveTab();
|
||||
});
|
||||
activeTab(newVal) {
|
||||
if (newVal === 'taxes') {
|
||||
this.loadBranchTaxes();
|
||||
}
|
||||
},
|
||||
selectedBranch: {
|
||||
handler(newBranch) {
|
||||
@@ -541,33 +537,6 @@ 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) {
|
||||
this.selectedBranch = newBranch;
|
||||
// Branches neu laden, um das Wetter zu aktualisieren
|
||||
@@ -580,8 +549,13 @@ export default {
|
||||
await this.loadVehicles();
|
||||
await this.loadProductPricesForCurrentBranch();
|
||||
this.$nextTick(() => {
|
||||
// Alle Tabs neu laden
|
||||
this.refreshActiveTab();
|
||||
this.$refs.directorInfo?.refresh();
|
||||
this.$refs.saleSection?.loadInventory();
|
||||
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
|
||||
@@ -598,9 +572,25 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade Preise für alle Produkte in der aktuellen Region
|
||||
const prices = {};
|
||||
for (const product of this.products) {
|
||||
if (!this.products || this.products.length === 0) {
|
||||
this.productPricesCache = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMIERUNG: Lade alle Preise in einem Batch-Request
|
||||
try {
|
||||
const productIds = this.products.map(p => p.id).join(',');
|
||||
const { data } = await apiClient.get('/api/falukant/products/prices-in-region-batch', {
|
||||
params: {
|
||||
productIds: productIds,
|
||||
regionId: this.selectedBranch.regionId
|
||||
}
|
||||
});
|
||||
this.productPricesCache = data || {};
|
||||
} catch (error) {
|
||||
console.error('Error loading prices in batch:', error);
|
||||
// Fallback: Lade Preise einzeln (aber parallel)
|
||||
const pricePromises = this.products.map(async (product) => {
|
||||
try {
|
||||
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
|
||||
params: {
|
||||
@@ -608,17 +598,23 @@ export default {
|
||||
regionId: this.selectedBranch.regionId
|
||||
}
|
||||
});
|
||||
prices[product.id] = data.price;
|
||||
} catch (error) {
|
||||
console.error(`Error loading price for product ${product.id}:`, error);
|
||||
return { productId: product.id, price: data.price };
|
||||
} catch (err) {
|
||||
console.error(`Error loading price for product ${product.id}:`, err);
|
||||
// Fallback auf Standard-Berechnung
|
||||
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
|
||||
const maxPrice = product.sellCost;
|
||||
const minPrice = maxPrice * 0.6;
|
||||
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
||||
return { productId: product.id, price: minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100) };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(pricePromises);
|
||||
this.productPricesCache = {};
|
||||
results.forEach(({ productId, price }) => {
|
||||
this.productPricesCache[productId] = price;
|
||||
});
|
||||
}
|
||||
this.productPricesCache = prices;
|
||||
},
|
||||
|
||||
formatPercent(value) {
|
||||
|
||||
@@ -274,12 +274,16 @@ export default {
|
||||
|
||||
getEffect(gift) {
|
||||
const relationship = this.relationships[0];
|
||||
if (!relationship || !relationship.character2) {
|
||||
return 0;
|
||||
}
|
||||
const partner = relationship.character2;
|
||||
const currentMoodId = partner.mood?.id ?? partner.mood_id;
|
||||
const currentMoodId = partner.moodId;
|
||||
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === currentMoodId);
|
||||
const moodValue = moodEntry ? moodEntry.suitability : 0;
|
||||
let highestCharacterValue = 0;
|
||||
for (const trait of partner.characterTrait) {
|
||||
// traits ist ein Array von Trait-Objekten mit id und tr
|
||||
for (const trait of partner.traits || []) {
|
||||
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
|
||||
if (charEntry && charEntry.suitability > highestCharacterValue) {
|
||||
highestCharacterValue = charEntry.suitability;
|
||||
|
||||
@@ -116,26 +116,15 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="falukantUser?.character" class="overview-visualization">
|
||||
<div class="character-3d-container">
|
||||
<CharacterModel3D
|
||||
:gender="falukantUser.character.gender"
|
||||
:age="falukantUser.character.age"
|
||||
:autoRotate="true"
|
||||
:rotationSpeed="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="imagecontainer">
|
||||
<div v-if="falukantUser?.character" class="imagecontainer">
|
||||
<div :style="getAvatarStyle" class="avatar"></div>
|
||||
<div :style="getHouseStyle" class="house"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
@@ -180,7 +169,6 @@ export default {
|
||||
name: 'FalukantOverviewView',
|
||||
components: {
|
||||
StatusBar,
|
||||
CharacterModel3D,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -493,27 +481,4 @@ h2 {
|
||||
padding: 20px;
|
||||
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>
|
||||
|
||||
@@ -50,13 +50,5 @@ export default defineConfig(({ mode }) => {
|
||||
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']
|
||||
};
|
||||
});
|
||||
|
||||
43
test-websocket-config.sh
Executable file
43
test-websocket-config.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== WebSocket-Konfiguration testen ==="
|
||||
echo ""
|
||||
|
||||
# Prüfe Apache-Module
|
||||
echo "1. Prüfe Apache-Module:"
|
||||
REQUIRED_MODULES=("proxy" "proxy_http" "proxy_wstunnel" "rewrite" "ssl" "headers")
|
||||
for module in "${REQUIRED_MODULES[@]}"; do
|
||||
if apache2ctl -M 2>/dev/null | grep -q "${module}_module"; then
|
||||
echo " ✅ $module ist aktiviert"
|
||||
else
|
||||
echo " ❌ $module ist NICHT aktiviert"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "2. Prüfe Apache-Konfiguration:"
|
||||
if sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
|
||||
echo " ✅ Konfiguration ist gültig"
|
||||
else
|
||||
echo " ❌ Konfiguration hat Fehler:"
|
||||
sudo apache2ctl configtest 2>&1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3. Prüfe aktive VirtualHosts:"
|
||||
apache2ctl -S 2>/dev/null | grep -E "(443|4443|4551)" || echo " Keine relevanten VirtualHosts gefunden"
|
||||
|
||||
echo ""
|
||||
echo "4. Prüfe Apache-Logs (letzte 20 Zeilen):"
|
||||
echo " Error-Log:"
|
||||
sudo tail -20 /var/log/apache2/yourpart.error.log 2>/dev/null || echo " Keine Fehler gefunden"
|
||||
echo ""
|
||||
echo " Access-Log (letzte 10 Zeilen mit /ws/ oder /socket.io/):"
|
||||
sudo tail -100 /var/log/apache2/yourpart.access.log 2>/dev/null | grep -E "(/ws/|/socket.io/)" | tail -10 || echo " Keine relevanten Einträge gefunden"
|
||||
|
||||
echo ""
|
||||
echo "5. Teste WebSocket-Verbindungen:"
|
||||
echo " Socket.io: wss://www.your-part.de/socket.io/"
|
||||
echo " Daemon: wss://www.your-part.de/ws/"
|
||||
echo ""
|
||||
echo " Bitte im Browser testen und dann die Logs prüfen."
|
||||
74
yourpart-https-alternative.conf
Normal file
74
yourpart-https-alternative.conf
Normal file
@@ -0,0 +1,74 @@
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost your-part.de:443>
|
||||
ServerAdmin webmaster@your-part.de
|
||||
ServerName your-part.de
|
||||
ServerAlias www.your-part.de
|
||||
|
||||
DocumentRoot /opt/yourpart/frontend/dist
|
||||
|
||||
DirectoryIndex index.html
|
||||
|
||||
# Frontend statische Dateien
|
||||
<Directory "/opt/yourpart/frontend/dist">
|
||||
AllowOverride None
|
||||
Options -Indexes +FollowSymLinks
|
||||
Require all granted
|
||||
|
||||
# Fallback für Vue Router
|
||||
FallbackResource /index.html
|
||||
</Directory>
|
||||
|
||||
# www Redirect (muss zuerst kommen)
|
||||
RewriteEngine on
|
||||
RewriteCond %{SERVER_NAME} =your-part.de
|
||||
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||
|
||||
# Proxy-Einstellungen
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
AllowEncodedSlashes NoDecode
|
||||
|
||||
# API-Requests an Backend weiterleiten
|
||||
ProxyPass "/api/" "http://localhost:2020/api/"
|
||||
ProxyPassReverse "/api/" "http://localhost:2020/api/"
|
||||
|
||||
# Socket.io: WebSocket und HTTP-Polling mit Location-Blöcken
|
||||
<LocationMatch "^/socket.io/">
|
||||
# WebSocket-Upgrade
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule .* "ws://localhost:2020%{REQUEST_URI}" [P,L]
|
||||
|
||||
# HTTP-Fallback für Polling
|
||||
ProxyPass "http://localhost:2020/socket.io/"
|
||||
ProxyPassReverse "http://localhost:2020/socket.io/"
|
||||
</LocationMatch>
|
||||
|
||||
# Daemon: WebSocket mit Location-Block
|
||||
<LocationMatch "^/ws/">
|
||||
# WebSocket-Upgrade
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule .* "ws://localhost:4551%{REQUEST_URI}" [P,L]
|
||||
|
||||
# HTTP-Fallback (sollte eigentlich nicht benötigt werden)
|
||||
ProxyPass "http://localhost:4551/"
|
||||
ProxyPassReverse "http://localhost:4551/"
|
||||
</LocationMatch>
|
||||
|
||||
ErrorLog /var/log/apache2/yourpart.error.log
|
||||
CustomLog /var/log/apache2/yourpart.access.log combined
|
||||
|
||||
HostnameLookups Off
|
||||
UseCanonicalName Off
|
||||
ServerSignature On
|
||||
|
||||
# SSL-Konfiguration
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
@@ -18,28 +18,23 @@
|
||||
FallbackResource /index.html
|
||||
</Directory>
|
||||
|
||||
# API-Requests an Backend weiterleiten
|
||||
ProxyPass "/api/" "http://localhost:2020/api/"
|
||||
ProxyPassReverse "/api/" "http://localhost:2020/api/"
|
||||
# Proxy-Einstellungen
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
AllowEncodedSlashes NoDecode
|
||||
|
||||
# WebSocket-Requests an Backend weiterleiten
|
||||
ProxyPass "/socket.io/" "http://localhost:2020/socket.io/"
|
||||
ProxyPassReverse "/socket.io/" "http://localhost:2020/socket.io/"
|
||||
|
||||
# WebSocket-Upgrade-Header für Socket.io
|
||||
# www Redirect (muss zuerst kommen, aber nicht für API-Pfade)
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/socket.io/(.*)$ "ws://localhost:2020/socket.io/$1" [P,L]
|
||||
RewriteCond %{SERVER_NAME} =your-part.de
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||
|
||||
# WebSocket-Upgrade-Header für Daemon-Verbindungen
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/ws/(.*)$ "ws://localhost:4551/$1" [P,L]
|
||||
|
||||
# WebSocket-Proxy für Daemon-Verbindungen mit benutzerdefiniertem Protokoll
|
||||
ProxyPass "/ws/" "ws://localhost:4551/" upgrade=websocket
|
||||
ProxyPassReverse "/ws/" "ws://localhost:4551/"
|
||||
# API-Requests an Backend weiterleiten (Location-Block hat höhere Priorität)
|
||||
<Location "/api/">
|
||||
ProxyPass "http://localhost:2020/api/"
|
||||
ProxyPassReverse "http://localhost:2020/api/"
|
||||
</Location>
|
||||
|
||||
ErrorLog /var/log/apache2/yourpart.error.log
|
||||
CustomLog /var/log/apache2/yourpart.access.log combined
|
||||
@@ -52,9 +47,5 @@
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||
|
||||
# www Redirect
|
||||
RewriteCond %{SERVER_NAME} =your-part.de
|
||||
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
|
||||
39
yourpart-websocket-fixed.conf
Normal file
39
yourpart-websocket-fixed.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
# /etc/apache2/sites-available/yourpart-websocket.conf
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:4551>
|
||||
ServerName www.your-part.de
|
||||
|
||||
# SSL aktivieren
|
||||
SSLEngine on
|
||||
Protocols http/1.1
|
||||
|
||||
# SSL-Konfiguration
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||
|
||||
# Proxy-Einstellungen
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
AllowEncodedSlashes NoDecode
|
||||
|
||||
# WebSocket-Upgrade (muss VOR ProxyPass stehen)
|
||||
# Apache lauscht auf Port 4551 (extern, verschlüsselt) und leitet an Daemon auf Port 4551 weiter (intern, unverschlüsselt)
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:4551/$1" [P,L]
|
||||
|
||||
# Fallback für normale HTTP-Requests (falls nötig)
|
||||
ProxyPass / http://localhost:4551/
|
||||
ProxyPassReverse / http://localhost:4551/
|
||||
|
||||
# CORS-Headers
|
||||
Header always set Access-Control-Allow-Origin "https://www.your-part.de"
|
||||
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
Header always set Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"
|
||||
|
||||
ErrorLog /var/log/apache2/yourpart-websocket.error.log
|
||||
CustomLog /var/log/apache2/yourpart-websocket.access.log combined
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
29
yourpart-ws-fixed.conf
Normal file
29
yourpart-ws-fixed.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:4443>
|
||||
ServerName www.your-part.de
|
||||
|
||||
SSLEngine on
|
||||
Protocols http/1.1
|
||||
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||
|
||||
ProxyPreserveHost On
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
AllowEncodedSlashes NoDecode
|
||||
|
||||
# WebSocket-Upgrade für Socket.io (muss VOR ProxyPass stehen)
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/socket.io/(.*)$ "ws://127.0.0.1:2020/socket.io/$1" [P,L]
|
||||
|
||||
# HTTP-Proxy für Socket.io (Fallback für Polling)
|
||||
ProxyPass "/socket.io/" "http://127.0.0.1:2020/socket.io/" retry=0
|
||||
ProxyPassReverse "/socket.io/" "http://127.0.0.1:2020/socket.io/"
|
||||
|
||||
ErrorLog /var/log/apache2/yourpart-ws.error.log
|
||||
CustomLog /var/log/apache2/yourpart-ws.access.log combined
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
Reference in New Issue
Block a user