first commit

This commit is contained in:
Colin-Joel Scupin
2026-08-05 01:29:39 +02:00
commit 86d4bcdc37
85 changed files with 7706 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
const resource = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'devx_characters';
const app = document.querySelector('#app');
const charactersEl = document.querySelector('#characters');
const createOverlay = document.querySelector('#createOverlay');
const deleteOverlay = document.querySelector('#deleteOverlay');
const createForm = document.querySelector('#createForm');
const newButton = document.querySelector('#newCharacter');
const playButton = document.querySelector('#playCharacter');
const deleteButton = document.querySelector('#deleteCharacter');
const sexSelect = document.querySelector('#sexSelect');
const messageEl = document.querySelector('#message');
const slotCounter = document.querySelector('#slotCounter');
const selectedName = document.querySelector('#selectedName');
const selectedDob = document.querySelector('#selectedDob');
const selectedSlot = document.querySelector('#selectedSlot');
const selectedPhone = document.querySelector('#selectedPhone');
const selectedStatus = document.querySelector('#selectedStatus');
const dialogue = document.querySelector('#dialogue');
const dialogueSpeaker = document.querySelector('#dialogueSpeaker');
const dialogueText = document.querySelector('#dialogueText');
const createButton = createForm.querySelector('button[type="submit"]');
const deleteConfirmInput = document.querySelector('#deleteConfirmInput');
const confirmDeleteButton = document.querySelector('#confirmDelete');
let maxCharacters = 3;
let characters = [];
let selectedId = null;
let deleteTarget = null;
async function post(event, data = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(`https://${resource}/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data),
signal: controller.signal
});
const body = await response.text();
if (!response.ok) throw new Error(`NUI ${event}: HTTP ${response.status}`);
if (!body) return { ok: true };
return JSON.parse(body);
} catch (error) {
if (error.name === 'AbortError') throw new Error('Der Server hat nach 30 Sekunden nicht geantwortet.');
throw error;
} finally {
clearTimeout(timeout);
}
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[char]));
}
function message(text = '', type = 'error') {
messageEl.textContent = text;
messageEl.dataset.type = type;
messageEl.classList.toggle('hidden', !text);
}
function selectedCharacter() {
return characters.find(character => Number(character.id) === Number(selectedId)) || null;
}
function formatDate(value) {
if (typeof value === 'number' || /^\d{11,}$/.test(String(value || ''))) {
const date = new Date(Number(value));
if (!Number.isNaN(date.getTime())) return date.toLocaleDateString('de-DE');
}
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : String(value || '');
}
function previewCharacter(character) {
const payload = character
? { model: character.model, sex: character.sex }
: { model: sexSelect.value === 'f' ? 'mp_f_freemode_01' : 'mp_m_freemode_01', sex: sexSelect.value };
post('preview', payload).catch(error => message(`Vorschaufehler: ${error.message}`));
}
function updateDetails() {
const character = selectedCharacter();
const hasCharacter = Boolean(character);
selectedName.textContent = hasCharacter ? `${character.firstName} ${character.lastName}` : 'Kein Charakter';
selectedDob.textContent = hasCharacter ? formatDate(character.dateOfBirth) : '';
selectedSlot.textContent = hasCharacter ? `Slot ${character.slot}` : '';
selectedPhone.textContent = hasCharacter ? (character.phoneNumber || 'Wird erstellt') : '';
selectedStatus.textContent = hasCharacter ? (character.introCompleted ? 'Einwohner' : 'Neue Ankunft') : '';
playButton.disabled = !hasCharacter;
deleteButton.disabled = !hasCharacter;
}
function selectCharacter(characterId, shouldPreview = true) {
selectedId = characterId == null ? null : Number(characterId);
document.querySelectorAll('.character-slot').forEach(card => {
card.classList.toggle('active', Number(card.dataset.characterId) === Number(selectedId));
});
updateDetails();
if (shouldPreview) previewCharacter(selectedCharacter());
}
function openCreator() {
if (characters.length >= maxCharacters) return;
message('');
createForm.reset();
createOverlay.classList.remove('hidden');
previewCharacter(null);
setTimeout(() => createForm.querySelector('input')?.focus(), 50);
}
function closeCreator() {
createOverlay.classList.add('hidden');
createForm.reset();
previewCharacter(selectedCharacter());
}
function openDelete() {
deleteTarget = selectedCharacter();
if (!deleteTarget) return;
document.querySelector('#deleteDescription').textContent = `${deleteTarget.firstName} ${deleteTarget.lastName} wird mit Bankkonto, Telefonnummer und Fortschritt dauerhaft gelöscht.`;
deleteConfirmInput.value = '';
confirmDeleteButton.disabled = true;
deleteOverlay.classList.remove('hidden');
setTimeout(() => deleteConfirmInput.focus(), 50);
}
function closeDelete() {
deleteOverlay.classList.add('hidden');
deleteTarget = null;
deleteConfirmInput.value = '';
}
function render() {
charactersEl.innerHTML = '';
slotCounter.textContent = `${characters.length} / ${maxCharacters}`;
for (let slot = 1; slot <= maxCharacters; slot += 1) {
const character = characters.find(entry => Number(entry.slot) === slot);
const card = document.createElement('button');
card.type = 'button';
card.className = `character-slot ${character ? '' : 'vacant'}`;
if (character) {
card.dataset.characterId = character.id;
card.innerHTML = `
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
<span class="slot-content">
<strong>${escapeHtml(character.firstName)} ${escapeHtml(character.lastName)}</strong>
<small>${character.introCompleted ? 'Einwohner von DevX-City' : 'Neue Ankunft'}</small>
</span>
<span class="slot-arrow"></span>`;
card.addEventListener('click', () => selectCharacter(character.id));
} else {
card.innerHTML = `
<span class="slot-number">${String(slot).padStart(2, '0')}</span>
<span class="slot-content"><strong>Freier Charakterplatz</strong><small>Neue Identität erstellen</small></span>
<span class="slot-plus">+</span>`;
card.addEventListener('click', openCreator);
}
charactersEl.appendChild(card);
}
newButton.disabled = characters.length >= maxCharacters;
if (!selectedCharacter()) selectedId = characters[0]?.id ?? null;
selectCharacter(selectedId, true);
}
playButton.addEventListener('click', async () => {
const character = selectedCharacter();
if (!character) return;
playButton.disabled = true;
message('Charakter wird geladen …', 'info');
try {
const result = await post('select', { characterId: Number(character.id) });
if (!result.ok) {
message(result.error || 'Charakter konnte nicht geladen werden.');
playButton.disabled = false;
}
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
playButton.disabled = false;
}
});
deleteButton.addEventListener('click', openDelete);
document.querySelector('#cancelDelete').addEventListener('click', closeDelete);
deleteConfirmInput.addEventListener('input', () => {
confirmDeleteButton.disabled = deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN';
});
confirmDeleteButton.addEventListener('click', async () => {
if (!deleteTarget || deleteConfirmInput.value.trim().toUpperCase() !== 'LÖSCHEN') return;
confirmDeleteButton.disabled = true;
const targetId = Number(deleteTarget.id);
closeDelete();
message('Charakter wird gelöscht …', 'info');
try {
const result = await post('delete', { characterId: targetId });
if (!result.ok) {
message(result.error || 'Löschen fehlgeschlagen.');
return;
}
characters = Array.isArray(result.characters) ? result.characters : characters.filter(entry => Number(entry.id) !== targetId);
selectedId = characters[0]?.id ?? null;
render();
message('Charakter wurde dauerhaft gelöscht.', 'success');
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
}
});
newButton.addEventListener('click', openCreator);
document.querySelector('#cancelCreate').addEventListener('click', closeCreator);
document.querySelector('#cancelCreateBottom').addEventListener('click', closeCreator);
sexSelect.addEventListener('change', () => previewCharacter(null));
createForm.addEventListener('submit', async event => {
event.preventDefault();
message('Charakter wird erstellt …', 'info');
createButton.disabled = true;
const oldLabel = createButton.textContent;
createButton.textContent = 'Wird erstellt …';
try {
const result = await post('create', Object.fromEntries(new FormData(createForm)));
if (!result.ok) {
message(result.error || 'Erstellung fehlgeschlagen.');
return;
}
characters = Array.isArray(result.characters) ? result.characters : characters;
selectedId = Number(result.characterId);
closeCreator();
render();
message(`Charakter erstellt. Telefonnummer: ${result.phoneNumber || 'wird geladen'}`, 'success');
} catch (error) {
message(`Verbindungsfehler: ${error.message}`);
} finally {
createButton.disabled = false;
createButton.textContent = oldLabel;
}
});
window.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
if (!deleteOverlay.classList.contains('hidden')) closeDelete();
else if (!createOverlay.classList.contains('hidden')) closeCreator();
});
window.addEventListener('message', event => {
const data = event.data || {};
if (data.action === 'open') app.classList.remove('hidden');
if (data.action === 'close') {
app.classList.add('hidden');
createOverlay.classList.add('hidden');
deleteOverlay.classList.add('hidden');
message('');
}
if (data.action === 'characters') {
characters = data.characters || [];
maxCharacters = Number(data.maxCharacters) || 3;
render();
}
if (data.action === 'error') message(data.message || 'Unbekannter Fehler.');
if (data.action === 'dialogue') {
dialogueSpeaker.textContent = data.speaker || 'Unbekannt';
dialogueText.textContent = data.text || '';
dialogue.classList.remove('hidden');
}
if (data.action === 'dialogueHide') dialogue.classList.add('hidden');
});
post('ready').catch(error => message(`Startfehler: ${error.message}`));
@@ -0,0 +1,107 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="app" class="hidden">
<aside class="selector-panel">
<header class="brand">
<div class="brand-mark">DX</div>
<div>
<p>DEVX-CITY ROLEPLAY</p>
<h1>Charakterauswahl</h1>
</div>
</header>
<div id="message" class="message hidden"></div>
<section class="character-section">
<div class="section-heading">
<span>DEINE CHARAKTERE</span>
<small id="slotCounter">0 / 3</small>
</div>
<div id="characters" class="characters"></div>
</section>
<section class="details">
<p class="details-kicker">AUSGEWÄHLTER CHARAKTER</p>
<h2 id="selectedName">Kein Charakter</h2>
<div class="detail-grid">
<div><span>Geburtsdatum</span><strong id="selectedDob"></strong></div>
<div><span>Charakterplatz</span><strong id="selectedSlot"></strong></div>
<div><span>Telefon</span><strong id="selectedPhone"></strong></div>
<div><span>Status</span><strong id="selectedStatus"></strong></div>
</div>
</section>
<footer class="main-actions">
<button id="deleteCharacter" class="danger" disabled>Löschen</button>
<button id="newCharacter" class="secondary">Neuer Charakter</button>
<button id="playCharacter" class="primary" disabled>Stadt betreten</button>
</footer>
</aside>
<div class="preview-hint">
<span>DEVX-CITY</span>
<strong>Wähle dein neues Leben.</strong>
</div>
<section id="createOverlay" class="modal-overlay hidden">
<form id="createForm" class="modal-card create-card">
<div class="modal-header">
<div>
<p>NEUE IDENTITÄT</p>
<h2>Charakter erstellen</h2>
</div>
<button type="button" id="cancelCreate" class="close-button" aria-label="Schließen">×</button>
</div>
<div class="form-grid">
<label><span>Vorname</span><input name="firstName" minlength="2" maxlength="32" autocomplete="off" required></label>
<label><span>Nachname</span><input name="lastName" minlength="2" maxlength="32" autocomplete="off" required></label>
<label><span>Geburtsdatum</span><input name="dateOfBirth" type="date" min="1900-01-01" max="2010-12-31" required></label>
<label><span>Geschlecht</span>
<select name="sex" id="sexSelect">
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
<option value="x">Divers</option>
</select>
</label>
</div>
<p class="form-note">Deine Telefonnummer und dein persönliches Bankkonto werden automatisch erstellt.</p>
<div class="modal-actions">
<button type="button" id="cancelCreateBottom" class="secondary">Abbrechen</button>
<button type="submit" class="primary">Charakter erstellen</button>
</div>
</form>
</section>
<section id="deleteOverlay" class="modal-overlay hidden">
<div class="modal-card delete-card">
<div class="warning-icon">!</div>
<h2>Charakter dauerhaft löschen?</h2>
<p id="deleteDescription">Dieser Vorgang kann nicht rückgängig gemacht werden.</p>
<label class="confirm-label">
<span>Tippe <strong id="deleteConfirmWord">LÖSCHEN</strong> zur Bestätigung</span>
<input id="deleteConfirmInput" autocomplete="off" placeholder="LÖSCHEN">
</label>
<div class="modal-actions">
<button id="cancelDelete" class="secondary">Abbrechen</button>
<button id="confirmDelete" class="danger" disabled>Endgültig löschen</button>
</div>
</div>
</section>
</main>
<section id="dialogue" class="dialogue hidden" aria-live="polite">
<strong id="dialogueSpeaker">Mason</strong>
<p id="dialogueText"></p>
</section>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,144 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f7f8fc;
--panel: rgba(9, 12, 18, .975);
--panel-soft: rgba(16, 20, 29, .985);
--line: rgba(255, 255, 255, .11);
--muted: #939cab;
--accent: #4d7cff;
--accent-bright: #7b9cff;
--danger: #d65464;
}
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; background: transparent; user-select: none; }
button, input, select { font: inherit; }
button { color: inherit; }
.hidden { display: none !important; }
#app {
position: fixed;
inset: 0;
background: linear-gradient(90deg, rgba(0, 2, 7, .45) 0, rgba(0, 2, 7, .08) 46%, transparent 72%);
}
.selector-panel {
width: min(455px, 36vw);
min-width: 390px;
height: 100vh;
padding: 34px 30px 26px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(13, 17, 25, .99), rgba(6, 8, 13, .985));
border-right: 1px solid var(--line);
box-shadow: 26px 0 70px rgba(0, 0, 0, .35);
}
.brand { display: flex; align-items: center; gap: 14px; margin-bottom: 28px; }
.brand-mark {
width: 46px; height: 46px; border-radius: 11px;
display: grid; place-items: center;
background: linear-gradient(145deg, var(--accent-bright), #345bd6);
box-shadow: 0 10px 28px rgba(67, 108, 234, .3);
font-weight: 900; letter-spacing: -.06em;
}
.brand p, .modal-header p { margin: 0 0 4px; color: var(--accent-bright); font-size: 9px; letter-spacing: .2em; font-weight: 850; }
.brand h1 { margin: 0; font-size: 24px; line-height: 1.1; letter-spacing: -.025em; }
.section-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.section-heading span { color: #c6cbd5; font-size: 9px; font-weight: 850; letter-spacing: .16em; }
.section-heading small { color: var(--muted); font-size: 12px; }
.characters { display: grid; gap: 8px; }
.character-slot {
width: 100%; min-height: 68px; padding: 0 14px;
border: 1px solid var(--line); border-radius: 8px;
display: grid; grid-template-columns: 36px 1fr 22px; align-items: center; gap: 11px;
text-align: left; cursor: pointer;
background: rgba(255, 255, 255, .034);
transition: border-color .16s ease, background .16s ease, transform .16s ease;
}
.character-slot:hover { background: rgba(255, 255, 255, .064); transform: translateX(3px); }
.character-slot.active {
border-color: rgba(83, 126, 255, .82);
background: linear-gradient(90deg, rgba(70, 111, 241, .22), rgba(255, 255, 255, .035));
box-shadow: inset 3px 0 0 var(--accent);
}
.character-slot.vacant { border-style: dashed; opacity: .7; }
.character-slot.vacant:hover { opacity: 1; }
.slot-number { color: #697386; font-size: 12px; font-weight: 850; }
.slot-content { display: grid; gap: 3px; }
.slot-content strong { font-size: 14px; font-weight: 780; }
.slot-content small { color: var(--muted); font-size: 10px; }
.slot-arrow, .slot-plus { justify-self: end; color: var(--accent-bright); font-size: 24px; font-weight: 300; }
.details {
margin-top: 20px; padding: 17px;
border: 1px solid var(--line); border-radius: 9px;
background: rgba(255, 255, 255, .027);
}
.details-kicker { margin: 0 0 7px; color: var(--muted); font-size: 8px; letter-spacing: .18em; font-weight: 850; }
.details h2 { margin: 0 0 15px; font-size: 21px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.detail-grid div { display: grid; gap: 4px; }
.detail-grid span { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
.detail-grid strong { font-size: 11px; font-weight: 680; }
.main-actions { margin-top: auto; padding-top: 20px; display: grid; grid-template-columns: .72fr 1fr 1.18fr; gap: 8px; }
button {
border: 0; border-radius: 7px; padding: 12px 13px;
font-weight: 780; cursor: pointer;
transition: filter .15s ease, transform .15s ease, opacity .15s ease;
}
button:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
button:disabled { opacity: .36; cursor: not-allowed; }
.primary { background: linear-gradient(135deg, var(--accent-bright), #3b65dd); }
.secondary { background: rgba(255, 255, 255, .085); border: 1px solid var(--line); }
.danger { background: rgba(198, 69, 85, .14); border: 1px solid rgba(214, 84, 100, .3); color: #ff9ca8; }
.preview-hint {
position: absolute; right: 42px; bottom: 36px;
display: grid; justify-items: end; gap: 5px;
text-shadow: 0 3px 16px rgba(0,0,0,.9); pointer-events: none;
}
.preview-hint span { color: var(--accent-bright); font-size: 10px; letter-spacing: .28em; font-weight: 900; }
.preview-hint strong { font-size: 16px; font-weight: 650; }
.message { margin: -10px 0 15px; padding: 10px 12px; border-radius: 7px; border: 1px solid rgba(214, 84, 100, .25); background: rgba(186, 52, 69, .13); color: #ffadb6; font-size: 11px; }
.message[data-type="info"] { border-color: rgba(80, 126, 255, .3); background: rgba(67, 108, 234, .14); color: #b8c9ff; }
.message[data-type="success"] { border-color: rgba(64, 181, 124, .3); background: rgba(51, 153, 104, .14); color: #a9ebc9; }
.modal-overlay { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(3, 5, 9, .68); }
.modal-card { width: min(610px, 88vw); padding: 27px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel-soft); box-shadow: 0 30px 100px rgba(0,0,0,.6); }
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 23px; }
.modal-header h2, .delete-card h2 { margin: 0; font-size: 26px; }
.close-button { width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); background: rgba(255,255,255,.06); font-size: 23px; font-weight: 400; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { display: grid; gap: 7px; }
label span { color: #b8bfcb; font-size: 10px; font-weight: 700; }
input, select { width: 100%; padding: 12px 13px; border: 1px solid var(--line); border-radius: 7px; outline: none; background: rgba(0,0,0,.27); color: white; }
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77,124,255,.12); }
option { color: black; }
.form-note { color: var(--muted); font-size: 11px; line-height: 1.55; }
.modal-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 21px; }
.delete-card { width: min(480px, 86vw); text-align: center; }
.warning-icon { width: 48px; height: 48px; margin: 0 auto 16px; display: grid; place-items: center; border-radius: 50%; background: rgba(214,84,100,.16); border: 1px solid rgba(214,84,100,.34); color: #ff9ca8; font-size: 24px; font-weight: 900; }
.delete-card > p { color: var(--muted); line-height: 1.55; }
.confirm-label { margin-top: 18px; text-align: left; }
.dialogue {
position: fixed; left: 50%; bottom: 6.5vh; transform: translateX(-50%);
width: min(940px, 84vw); text-align: center;
pointer-events: none;
text-shadow: 0 2px 4px #000, 0 0 14px rgba(0,0,0,.95);
}
.dialogue strong {
display: block; margin-bottom: 6px;
color: #79a0ff; font-size: 13px; letter-spacing: .09em; text-transform: uppercase;
}
.dialogue p { margin: 0; font-size: 20px; line-height: 1.38; font-weight: 650; }
@media (max-width: 1050px) {
.selector-panel { width: 420px; min-width: 420px; }
.preview-hint { display: none; }
}