No story nodes created. Click "+ Add Story Node" to begin.
';
}
state.storyNodes.forEach(node => {
const card = document.createElement('div');
card.className = 'rpg-node-card';
card.dataset.nodeId = node.id;
// Choices HTML
let choicesHTML = '
';
// Full card HTML
card.innerHTML = `
Choices
${choicesHTML}
`;
nodeLiseContainer.appendChild(card);
});
}
function renderPlaytestView() {
const { currentNodeId, lastOutcome } = state.ui;
if (currentNodeId === null) {
// Start screen
playtestTitle.textContent = "Playtest Adventure";
playtestStoryText.textContent = state.adventure.intro;
playtestChoicesList.innerHTML = '';
playtestStartBtn.style.display = 'block';
playtestRestartBtn.style.display = 'none';
playtestOutcome.style.display = 'none';
return;
}
const node = state.storyNodes.find(n => n.id === currentNodeId);
if (!node) {
// End of branch
playtestStoryText.textContent = "This branch of the story has ended.";
playtestChoicesList.innerHTML = '';
playtestStartBtn.style.display = 'none';
playtestRestartBtn.style.display = 'block';
} else {
// Render the current node
playtestTitle.textContent = node.title;
playtestStoryText.textContent = node.text;
playtestChoicesList.innerHTML = '';
node.choices.forEach(choice => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'rpg-btn rpg-playtest-choice-btn';
btn.textContent = choice.text;
btn.dataset.leadsTo = choice.leadsToNodeId;
btn.dataset.outcome = choice.outcome;
playtestChoicesList.appendChild(btn);
});
playtestStartBtn.style.display = 'none';
playtestRestartBtn.style.display = 'block';
}
// Show outcome from *last* choice
if (lastOutcome) {
playtestOutcome.textContent = lastOutcome;
playtestOutcome.style.display = 'block';
} else {
playtestOutcome.style.display = 'none';
}
}
// --- EVENT LISTENERS --- //
function attachTabListeners() {
const tabIds = Array.from(tabLinks).map(link => link.dataset.tab);
function showTab(tabId) {
state.ui.activeTab = tabId;
tabContents.forEach(c => c.classList.toggle('rpg-active', c.id === tabId));
tabLinks.forEach(l => l.classList.toggle('rpg-active', l.dataset.tab === tabId));
const currentIndex = tabIds.indexOf(tabId);
prevTabBtn.style.visibility = (currentIndex === 0) ? 'hidden' : 'visible';
nextTabBtn.style.visibility = (currentIndex === tabIds.length - 1) ? 'hidden' : 'visible';
}
tabLinks.forEach(link => link.addEventListener('click', () => showTab(link.dataset.tab)));
nextTabBtn.addEventListener('click', () => {
const currentIndex = tabIds.indexOf(state.ui.activeTab);
if (currentIndex < tabIds.length - 1) showTab(tabIds[currentIndex + 1]);
});
prevTabBtn.addEventListener('click', () => {
const currentIndex = tabIds.indexOf(state.ui.activeTab);
if (currentIndex > 0) showTab(tabIds[currentIndex - 1]);
});
}
function attachDashboardListeners() {
// Core Details
titleInput.addEventListener('input', (e) => state.adventure.title = e.target.value);
introTextarea.addEventListener('input', (e) => state.adventure.intro = e.target.value);
titleInput.addEventListener('change', saveState);
introTextarea.addEventListener('change', saveState);
// Player Template
addStatBtn.addEventListener('click', () => {
const stat = addStatSelect.value;
if (stat && !state.playerTemplate.stats.includes(stat)) {
state.playerTemplate.stats.push(stat);
updateAll();
}
});
addItemBtn.addEventListener('click', () => {
const item = newItemInput.value.trim();
if (item && !state.playerTemplate.inventory.includes(item)) {
state.playerTemplate.inventory.push(item);
newItemInput.value = '';
updateAll();
}
});
container.addEventListener('click', (e) => {
// Remove stat/item from template
if (e.target.classList.contains('rpg-remove-item')) {
const type = e.target.dataset.type;
const id = e.target.dataset.id;
if (type === 'stat') {
state.playerTemplate.stats = state.playerTemplate.stats.filter(s => s !== id);
} else if (type === 'item') {
state.playerTemplate.inventory = state.playerTemplate.inventory.filter(i => i !== id);
}
updateAll();
}
});
// Node Editor
addNodeBtn.addEventListener('click', () => {
const newNode = {
id: `n${Date.now()}`,
title: "New Story Node",
text: "",
choices: []
};
state.storyNodes.push(newNode);
updateAll();
});
// Node Editor (Event Delegation)
nodeLiseContainer.addEventListener('click', (e) => {
const nodeCard = e.target.closest('.rpg-node-card');
if (!nodeCard) return;
const nodeId = nodeCard.dataset.nodeId;
const node = state.storyNodes.find(n => n.id === nodeId);
if (!node) return;
// Remove Node
if (e.target.classList.contains('rpg-remove-node-btn')) {
state.storyNodes = state.storyNodes.filter(n => n.id !== nodeId);
// Also remove choices leading to this node
state.storyNodes.forEach(n => {
n.choices.forEach(c => {
if (c.leadsToNodeId === nodeId) c.leadsToNodeId = null;
});
});
updateAll();
}
// Add Choice
if (e.target.classList.contains('rpg-add-choice-btn')) {
node.choices.push({
id: `c${Date.now()}`,
text: "New Choice",
leadsToNodeId: null,
outcome: ""
});
updateAll();
}
// Remove Choice
if (e.target.classList.contains('rpg-remove-choice-btn')) {
const choiceId = e.target.dataset.choiceId;
node.choices = node.choices.filter(c => c.id !== choiceId);
updateAll();
}
});
nodeLiseContainer.addEventListener('input', (e) => {
const nodeCard = e.target.closest('.rpg-node-card');
if (!nodeCard) return;
const nodeId = nodeCard.dataset.nodeId;
const node = state.storyNodes.find(n => n.id === nodeId);
if (!node) return;
// Update Node Title
if (e.target.classList.contains('rpg-node-title-input')) {
node.title = e.target.value;
// This requires a full re-render of dropdowns
renderNodeEditors();
}
// Update Node Text
if (e.target.classList.contains('rpg-node-text-input')) {
node.text = e.target.value;
}
// Update Choice Fields
if (e.target.classList.contains('rpg-choice-input')) {
const choiceId = e.target.closest('.rpg-choice-item').dataset.choiceId;
const choice = node.choices.find(c => c.id === choiceId);
const field = e.target.dataset.field;
if (choice) {
choice[field] = e.target.value;
}
}
saveState();
});
}
function attachPlaytestListeners() {
// Toggle Editor/Playtest
const toggleViews = () => {
state.ui.playMode = !state.ui.playMode;
editorView.style.display = state.ui.playMode ? 'none' : 'block';
playtestView.style.display = state.ui.playMode ? 'block' : 'none';
container.querySelector('#rpg-pdf-download-container').style.display = state.ui.playMode ? 'none' : 'block';
if (state.ui.playMode) {
// Reset to start screen
state.ui.currentNodeId = null;
state.ui.lastOutcome = null;
renderPlaytestView();
}
};
playtestToggleBtn.addEventListener('click', toggleViews);
editorToggleBtn.addEventListener('click', toggleViews);
// Start button
playtestStartBtn.addEventListener('click', () => {
if (state.storyNodes.length > 0) {
state.ui.currentNodeId = state.storyNodes[0].id; // Start at first node
state.ui.lastOutcome = null;
renderPlaytestView();
} else {
alert("No story nodes have been created yet!");
}
});
// Restart button
playtestRestartBtn.addEventListener('click', () => {
state.ui.currentNodeId = null;
state.ui.lastOutcome = null;
renderPlaytestView();
});
// Choice buttons
playtestChoicesList.addEventListener('click', (e) => {
if (e.target.classList.contains('rpg-playtest-choice-btn')) {
state.ui.currentNodeId = e.target.dataset.leadsTo || null;
state.ui.lastOutcome = e.target.dataset.outcome || null;
renderPlaytestView();
}
});
}
function attachConfigListeners() {
// Add Stat Name
addStatConfigBtn.addEventListener('click', () => {
const name = newStatNameInput.value.trim();
if (name && !state.config.availableStats.includes(name)) {
state.config.availableStats.push(name);
newStatNameInput.value = '';
updateAll();
}
});
// Remove Stat Name (Delegation)
statConfigList.addEventListener('click', (e) => {
if (e.target.classList.contains('rpg-remove-config-item')) {
const name = e.target.dataset.name;
state.config.availableStats = state.config.availableStats.filter(s => s !== name);
// Also remove from player template if it was in use
state.playerTemplate.stats = state.playerTemplate.stats.filter(s => s !== name);
updateAll();
}
});
}
function attachPdfListener() {
downloadPdfBtn.addEventListener('click', () => {
// Ensure jsPDF and autoTable are loaded
if (typeof jspdf === 'undefined' || !jspdf.jsPDF) {
alert('Error: PDF library (jsPDF) not loaded.');
return;
}
if (typeof jspdf.jsPDF.autoTable !== 'function') {
alert('Error: PDF library (autoTable) not loaded.');
return;
}
const { jsPDF } = jspdf;
const doc = new jsPDF();
let cursorY = 20; // Y position tracker
// 1. Title
doc.setFontSize(22);
doc.text(state.adventure.title, doc.internal.pageSize.getWidth() / 2, cursorY, { align: 'center' });
cursorY += 15;
// 2. Introduction
doc.setFontSize(18);
doc.text("Introduction", 14, cursorY);
cursorY += 8;
doc.setFontSize(12);
// Split long text
const introLines = doc.splitTextToSize(state.adventure.intro, 180);
doc.text(introLines, 14, cursorY);
cursorY += (introLines.length * 7) + 10;
// 3. Player Template
doc.setFontSize(18);
doc.text("Player Character Template", 14, cursorY);
cursorY += 8;
doc.setFontSize(12);
doc.text("Starting Stats:", 14, cursorY);
doc.text(state.playerTemplate.stats.join(', ') || 'N/A', 45, cursorY);
cursorY += 7;
doc.text("Starting Inventory:", 14, cursorY);
doc.text(state.playerTemplate.inventory.join(', ') || 'N/A', 55, cursorY);
cursorY += 15;
// 4. Story Nodes
doc.setFontSize(18);
doc.text("Story Nodes", 14, cursorY);
cursorY += 8;
const getNodeTitle = (nodeId) => {
if (!nodeId) return "(End of Branch)";
const node = state.storyNodes.find(n => n.id === nodeId);
return node ? node.title : "(Invalid Node)";
};
state.storyNodes.forEach((node, index) => {
// Check for page break
if (cursorY > 250) {
doc.addPage();
cursorY = 20;
}
doc.setFontSize(16);
doc.setFont(undefined, 'bold');
doc.text(node.title, 14, cursorY);
cursorY += 8;
doc.setFont(undefined, 'normal');
doc.setFontSize(12);
const storyLines = doc.splitTextToSize(node.text, 180);
doc.text(storyLines, 14, cursorY);
cursorY += (storyLines.length * 7) + 5;
const choiceBody = node.choices.map(choice => [
choice.text,
getNodeTitle(choice.leadsToNodeId),
choice.outcome || 'N/A'
]);
doc.autoTable({
head: [['Choice Text', 'Leads To', 'Outcome']],
body: choiceBody,
startY: cursorY,
theme: 'grid',
headStyles: { fillColor: [0, 123, 255] }
});
cursorY = doc.lastAutoTable.finalY + 15;
});
// 5. Save
const safeFileName = (state.adventure.title || 'adventure-module').replace(/[^a-z0-9]/gi, '_').toLowerCase();
doc.save(`${safeFileName}.pdf`);
});
}
// --- INITIALIZATION --- //
function init() {
loadState();
attachTabListeners();
attachDashboardListeners();
attachPlaytestListeners();
attachConfigListeners();
attachPdfListener();
updateAll(); // Initial render of all components
}
init();
});