Changes
diff --git a/components.js b/components.js
new file mode 100644
index 0000000..3d99ca9
--- /dev/null
+++ b/components.js
@@ -0,0 +1,89 @@
+import { html } from 'https://unpkg.com/htm/preact/standalone.module.js';
+import { THEMES } from './game.js';
+
+export const Card = ({ card, onClick, is_disabled, onAttack, can_use_weapon, themeKey }) => {
+ if (!card) return html`<div class="card-slot-empty"></div>`;
+
+ const handleWeaponClick = (e) => {
+ e.stopPropagation();
+ onAttack(card, true);
+ };
+
+ const handleBareHandClick = (e) => {
+ e.stopPropagation();
+ onAttack(card, false);
+ };
+
+ const theme = THEMES[themeKey];
+
+ const renderCardContent = () => {
+ if (themeKey === 'Text') {
+ return html`
+ <div class="card-corner top-left">
+ <span>${card.rank}</span>
+ <span>${card.suit}</span>
+ </div>
+ <div class="card-center">
+ ${card.suit}
+ </div>
+ <div class="card-corner bottom-right">
+ <span>${card.rank}</span>
+ <span>${card.suit}</span>
+ </div>
+ `;
+ } else {
+ const code = `${card.rank}${card.suit_key[0]}`.toLowerCase();
+ const src = `${theme.path}/${code}.${theme.ext}`;
+ return html`<img src="${src}" class="card-image" alt="${card.rank}${card.suit}" />`;
+ }
+ };
+
+ return html`
+ <div class="card ${card.color} ${is_disabled ? 'disabled' : ''}" onClick=${() => !is_disabled && onClick(card)}>
+ ${renderCardContent()}
+ ${card.type === 'MONSTER' && !is_disabled ? html`
+ <div class="card-overlay">
+ <button class="overlay-btn" title="Attack with Weapon" disabled=${!can_use_weapon} onClick=${handleWeaponClick}>
+ 🗡️
+ </button>
+ <button class="overlay-btn" title="Bare-handed" onClick=${handleBareHandClick}>
+ 👊
+ </button>
+ </div>
+ ` : ''}
+ </div>
+ `;
+};
+
+export const WeaponSlot = ({ weapon, last_enemy_value, themeKey }) => {
+ if (!weapon) {
+ return html`
+ <div class="weapon-slot"><span>No Weapon</span></div>
+ <div class="weapon-info">
+ <p><strong>Unarmed</strong></p>
+ <p>Attack Power: 0</p>
+ </div>
+ `;
+ }
+
+ const max_kill_msg = last_enemy_value === Infinity ? "Any" : `< ${last_enemy_value}`;
+ return html`
+ <div class="weapon-slot"><${Card} card=${weapon} is_disabled=${true} themeKey=${themeKey} /></div>
+ <div class="weapon-info">
+ <p><strong>Equipped: ${weapon.rank}${weapon.suit}</strong></p>
+ <p>Power: ${weapon.value} | Max Kill: ${max_kill_msg}</p>
+ </div>
+ `;
+};
+
+export const GameOver = ({ won, hp, reset_game }) => {
+ const title = won ? "VICTORY!" : "YOU DIED";
+ const css_class = won ? "win" : "lose";
+ return html`
+ <div class="game-over ${css_class}">
+ <h2>${title}</h2>
+ <p>Final Health: ${won ? hp : "DEAD"}</p>
+ <button onClick=${reset_game}>Play Again</button>
+ </div>
+ `;
+};
diff --git a/game.js b/game.js
new file mode 100644
index 0000000..a67ade2
--- /dev/null
+++ b/game.js
@@ -0,0 +1,110 @@
+// --- Constants ---
+export const MAX_HP = 20;
+export const MAX_UNDO_STEPS = 1;
+export const STORAGE_KEY = 'scoundrel_state_v1';
+
+export const SUITS = {
+ HEARTS: { symbol: '♥', color: 'red', type: 'POTION' },
+ DIAMONDS: { symbol: '♦', color: 'red', type: 'WEAPON' },
+ CLUBS: { symbol: '♣', color: 'black', type: 'MONSTER' },
+ SPADES: { symbol: '♠', color: 'black', type: 'MONSTER' }
+};
+
+export const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
+
+export const THEMES = {
+ 'Text': { label: 'Classic (Text)' },
+ 'SimpleSVG': { label: 'Simple SVG', ext: 'svg', path: 'assets/themes/SimpleSVG' }
+};
+
+export const INITIAL_STATE = {
+ hp: 20,
+ max_hp: 20,
+ deck: [],
+ room: [null, null, null, null], // Fixed indices
+ discard_pile: [],
+ defeated_monsters: [],
+ weapon: null,
+ last_enemy_value: Infinity,
+ cards_played_this_turn: 0,
+ potion_used_this_turn: false,
+ can_flee: true,
+ game_over: false,
+ won: false,
+ fled_last_turn: false,
+ msg: "Welcome to the dungeon.",
+ theme: 'Text',
+ history: []
+};
+
+// --- Helpers ---
+export const getCardValue = (rank) => {
+ if (rank === 'A') return 14;
+ if (rank === 'K') return 13;
+ if (rank === 'Q') return 12;
+ if (rank === 'J') return 11;
+ return parseInt(rank, 10);
+};
+
+export const createDeck = () => {
+ let deck = [];
+ let id_counter = 0;
+ for (const [suit_key, suit_data] of Object.entries(SUITS)) {
+ for (const rank of RANKS) {
+ if ((suit_key === 'HEARTS' || suit_key === 'DIAMONDS') && ['J', 'Q', 'K', 'A'].includes(rank)) continue;
+ deck.push({
+ id: `card-${id_counter++}`,
+ suit: suit_data.symbol,
+ color: suit_data.color,
+ type: suit_data.type,
+ rank: rank,
+ value: getCardValue(rank),
+ suit_key: suit_key
+ });
+ }
+ }
+ return deck;
+};
+
+export const shuffleDeck = (deck) => {
+ const new_deck = [...deck];
+ for (let i = new_deck.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [new_deck[i], new_deck[j]] = [new_deck[j], new_deck[i]];
+ }
+ return new_deck;
+};
+
+export const saveHistory = (currentState) => {
+ // Deep copy state excluding history itself to avoid recursion/heavy memory usage
+ const { history, ...stateToSave } = currentState;
+ // Basic deep copy for this simple state structure (arrays/objects)
+ const snapshot = JSON.parse(JSON.stringify(stateToSave));
+ // Limit history to MAX_UNDO_STEPS
+ return [...history, snapshot].slice(-MAX_UNDO_STEPS);
+};
+
+export const saveState = (state) => {
+ try {
+ // Convert Infinity to a placeholder string for JSON serialization
+ const serialized = JSON.stringify(state, (key, value) => {
+ return value === Infinity ? '___Infinity___' : value;
+ });
+ localStorage.setItem(STORAGE_KEY, serialized);
+ } catch (e) {
+ console.error("Failed to save state:", e);
+ }
+};
+
+export const loadState = () => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (!saved) return null;
+ return JSON.parse(saved, (key, value) => {
+ return value === '___Infinity___' ? Infinity : value;
+ });
+ } catch (e) {
+ console.error("Failed to load state:", e);
+ return null;
+ }
+};
diff --git a/main.js b/main.js
index ac37b34..8deebb3 100644
--- a/main.js
+++ b/main.js
@@ -1,197 +1,9 @@
import { h, render, useState, useEffect, html } from 'https://unpkg.com/htm/preact/standalone.module.js';
-
-// --- Constants ---
-const MAX_HP = 20;
-const MAX_UNDO_STEPS = 1;
-const SUITS = {
- HEARTS: { symbol: '♥', color: 'red', type: 'POTION' },
- DIAMONDS: { symbol: '♦', color: 'red', type: 'WEAPON' },
- CLUBS: { symbol: '♣', color: 'black', type: 'MONSTER' },
- SPADES: { symbol: '♠', color: 'black', type: 'MONSTER' }
-};
-
-const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
-
-const THEMES = {
- 'Text': { label: 'Classic (Text)' },
- 'SimpleSVG': { label: 'Simple SVG', ext: 'svg', path: 'assets/themes/SimpleSVG' }
-};
-
-// --- Helpers ---
-const getCardValue = (rank) => {
- if (rank === 'A') return 14;
- if (rank === 'K') return 13;
- if (rank === 'Q') return 12;
- if (rank === 'J') return 11;
- return parseInt(rank, 10);
-};
-
-const createDeck = () => {
- let deck = [];
- let id_counter = 0;
- for (const [suit_key, suit_data] of Object.entries(SUITS)) {
- for (const rank of RANKS) {
- if ((suit_key === 'HEARTS' || suit_key === 'DIAMONDS') && ['J', 'Q', 'K', 'A'].includes(rank)) continue;
- deck.push({
- id: `card-${id_counter++}`,
- suit: suit_data.symbol,
- color: suit_data.color,
- type: suit_data.type,
- rank: rank,
- value: getCardValue(rank),
- suit_key: suit_key
- });
- }
- }
- return deck;
-};
-
-const shuffleDeck = (deck) => {
- const new_deck = [...deck];
- for (let i = new_deck.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [new_deck[i], new_deck[j]] = [new_deck[j], new_deck[i]];
- }
- return new_deck;
-};
-
-// --- Components ---
-
-const Card = ({ card, onClick, is_disabled, onAttack, can_use_weapon, themeKey }) => {
- if (!card) return html`<div class="card-slot-empty"></div>`;
-
- const handleWeaponClick = (e) => {
- e.stopPropagation();
- onAttack(card, true);
- };
-
- const handleBareHandClick = (e) => {
- e.stopPropagation();
- onAttack(card, false);
- };
-
- const theme = THEMES[themeKey];
-
- const renderCardContent = () => {
- if (themeKey === 'Text') {
- return html`
- <div class="card-corner top-left">
- <span>${card.rank}</span>
- <span>${card.suit}</span>
- </div>
- <div class="card-center">
- ${card.suit}
- </div>
- <div class="card-corner bottom-right">
- <span>${card.rank}</span>
- <span>${card.suit}</span>
- </div>
- `;
- } else {
- const code = `${card.rank}${card.suit_key[0]}`.toLowerCase();
- const src = `${theme.path}/${code}.${theme.ext}`;
- return html`<img src="${src}" class="card-image" alt="${card.rank}${card.suit}" />`;
- }
- };
-
- return html`
- <div class="card ${card.color} ${is_disabled ? 'disabled' : ''}" onClick=${() => !is_disabled && onClick(card)}>
- ${renderCardContent()}
- ${card.type === 'MONSTER' && !is_disabled ? html`
- <div class="card-overlay">
- <button class="overlay-btn" title="Attack with Weapon" disabled=${!can_use_weapon} onClick=${handleWeaponClick}>
- 🗡️
- </button>
- <button class="overlay-btn" title="Bare-handed" onClick=${handleBareHandClick}>
- 👊
- </button>
- </div>
- ` : ''}
- </div>
- `;
-};
-
-const WeaponSlot = ({ weapon, last_enemy_value, themeKey }) => {
- if (!weapon) {
- return html`
- <div class="weapon-slot"><span>No Weapon</span></div>
- <div class="weapon-info">
- <p><strong>Unarmed</strong></p>
- <p>Attack Power: 0</p>
- </div>
- `;
- }
-
- const max_kill_msg = last_enemy_value === Infinity ? "Any" : `< ${last_enemy_value}`;
- return html`
- <div class="weapon-slot"><${Card} card=${weapon} is_disabled=${true} themeKey=${themeKey} /></div>
- <div class="weapon-info">
- <p><strong>Equipped: ${weapon.rank}${weapon.suit}</strong></p>
- <p>Power: ${weapon.value} | Max Kill: ${max_kill_msg}</p>
- </div>
- `;
-};
-
-const GameOver = ({ won, hp, reset_game }) => {
- const title = won ? "VICTORY!" : "YOU DIED";
- const css_class = won ? "win" : "lose";
- return html`
- <div class="game-over ${css_class}">
- <h2>${title}</h2>
- <p>Final Health: ${won ? hp : "DEAD"}</p>
- <button onClick=${reset_game}>Play Again</button>
- </div>
- `;
-};
-
-// --- Main App ---
-
-const INITIAL_STATE = {
- hp: 20,
- max_hp: 20,
- deck: [],
- room: [null, null, null, null], // Fixed indices
- discard_pile: [],
- defeated_monsters: [],
- weapon: null,
- last_enemy_value: Infinity,
- cards_played_this_turn: 0,
- potion_used_this_turn: false,
- can_flee: true,
- game_over: false,
- won: false,
- fled_last_turn: false,
- msg: "Welcome to the dungeon.",
- theme: 'Text',
- history: []
-};
-
-const STORAGE_KEY = 'scoundrel_state_v1';
-
-const saveState = (state) => {
- try {
- // Convert Infinity to a placeholder string for JSON serialization
- const serialized = JSON.stringify(state, (key, value) => {
- return value === Infinity ? '___Infinity___' : value;
- });
- localStorage.setItem(STORAGE_KEY, serialized);
- } catch (e) {
- console.error("Failed to save state:", e);
- }
-};
-
-const loadState = () => {
- try {
- const saved = localStorage.getItem(STORAGE_KEY);
- if (!saved) return null;
- return JSON.parse(saved, (key, value) => {
- return value === '___Infinity___' ? Infinity : value;
- });
- } catch (e) {
- console.error("Failed to load state:", e);
- return null;
- }
-};
+import {
+ INITIAL_STATE, THEMES,
+ createDeck, shuffleDeck, saveHistory, saveState, loadState
+} from './game.js';
+import { Card, WeaponSlot, GameOver } from './components.js';
const App = () => {
const [state, setState] = useState(() => {
@@ -232,15 +44,6 @@ const App = () => {
}));
};
- const saveHistory = (currentState) => {
- // Deep copy state excluding history itself to avoid recursion/heavy memory usage
- const { history, ...stateToSave } = currentState;
- // Basic deep copy for this simple state structure (arrays/objects)
- const snapshot = JSON.parse(JSON.stringify(stateToSave));
- // Limit history to MAX_UNDO_STEPS
- return [...history, snapshot].slice(-MAX_UNDO_STEPS);
- };
-
const undoLastMove = () => {
if (state.history.length === 0) return;