Add Mission Tab: combat simulation for GM mission planning
- New MissionTab component with scene-based combat simulation - Enemy roster from DeathwatchRoller (12 enemy types) - Scene progression (4-6 scenes) with threat levels - Combat log and results tracking - Added to App.js navigation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import BestiaryTab from './components/BestiaryTab';
|
|||||||
import WeaponsTab from './components/WeaponsTab';
|
import WeaponsTab from './components/WeaponsTab';
|
||||||
import GMKit from './components/GMKit';
|
import GMKit from './components/GMKit';
|
||||||
import PlayerManagement from './components/PlayerManagement';
|
import PlayerManagement from './components/PlayerManagement';
|
||||||
|
import MissionTab from './components/MissionTab';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
|
import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger';
|
||||||
@@ -296,6 +297,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
Weapons
|
Weapons
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='mission' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
|
||||||
|
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'mission' }); setTab('mission')}}
|
||||||
|
>
|
||||||
|
Mission Sim
|
||||||
|
</button>
|
||||||
<a
|
<a
|
||||||
href="/cards.html"
|
href="/cards.html"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -371,7 +378,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='weapons' ? <WeaponsTab /> : tab==='mission' ? <MissionTab authedPlayer={authedPlayer} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
||||||
authedPlayer={authedPlayer}
|
authedPlayer={authedPlayer}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
455
src/components/MissionTab.jsx
Normal file
455
src/components/MissionTab.jsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Tooltip } from './DeathwatchRoller';
|
||||||
|
|
||||||
|
// Inline combat helpers (mirrors DeathwatchRoller logic)
|
||||||
|
function d100() { return Math.floor(Math.random() * 100) + 1; }
|
||||||
|
function degrees(target, roll) {
|
||||||
|
const success = roll <= target;
|
||||||
|
if (success) { const diff = target - roll; return { success, dos: 1 + Math.floor(diff / 10), dof: 0 }; }
|
||||||
|
const diff = roll - target; return { success, dos: 0, dof: 1 + Math.floor(diff / 10) };
|
||||||
|
}
|
||||||
|
function hitsFromDoS(mode, dos, rof) {
|
||||||
|
const r = rof && rof > 0 ? rof : 1;
|
||||||
|
if (mode === 'single') return Math.min(1, r);
|
||||||
|
if (mode === 'semi') return Math.max(1, Math.min(1 + Math.floor(dos / 2), r));
|
||||||
|
return Math.max(1, Math.min(1 + dos, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENE_TYPES = ['Ambush', 'Assault', 'Defense', 'Infiltration', 'Search', 'Escort'];
|
||||||
|
const THREAT_LEVELS = ['Low', 'Medium', 'High', 'Extreme'];
|
||||||
|
|
||||||
|
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
|
||||||
|
|
||||||
|
function MissionTab({ authedPlayer }) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [scenes, setScenes] = useState([]);
|
||||||
|
const [currentScene, setCurrentScene] = useState(0);
|
||||||
|
const [threat, setThreat] = useState('Medium');
|
||||||
|
const [missionName, setMissionName] = useState('');
|
||||||
|
const [enemies, setEnemies] = useState([]);
|
||||||
|
const [players, setPlayers] = useState([]);
|
||||||
|
const [combatLog, setCombatLog] = useState([]);
|
||||||
|
const [sceneComplete, setSceneComplete] = useState(false);
|
||||||
|
const [sceneResult, setSceneResult] = useState(null);
|
||||||
|
const [showSetup, setShowSetup] = useState(true);
|
||||||
|
const [showCombat, setShowCombat] = useState(false);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
|
||||||
|
// Setup state
|
||||||
|
const [sceneCount, setSceneCount] = useState(4);
|
||||||
|
const [enemyType, setEnemyType] = useState('Tyranid Warrior');
|
||||||
|
const [enemyCount, setEnemyCount] = useState(3);
|
||||||
|
const [playerCount, setPlayerCount] = useState(2);
|
||||||
|
|
||||||
|
// Combat state
|
||||||
|
const [selectedEnemy, setSelectedEnemy] = useState(null);
|
||||||
|
const [selectedPlayer, setSelectedPlayer] = useState(null);
|
||||||
|
const [attackRoll, setAttackRoll] = useState(null);
|
||||||
|
const [defenseRoll, setDefenseRoll] = useState(null);
|
||||||
|
const [damageRoll, setDamageRoll] = useState(null);
|
||||||
|
const [attackResult, setAttackResult] = useState(null);
|
||||||
|
|
||||||
|
// Predefined enemy types from DeathwatchRoller
|
||||||
|
const ENEMY_TYPES = [
|
||||||
|
{ name: 'Custom/None', tb: 4, armour: 5, wounds: 20 },
|
||||||
|
{ name: 'Imperial Guardsman', tb: 3, armourByLoc: { 'Head': 4, 'Body': 4, 'Left Arm': 4, 'Right Arm': 4, 'Left Leg': 4, 'Right Leg': 4 }, wounds: 10 },
|
||||||
|
{ name: 'Chaos Space Marine', tb: 8, armourByLoc: { 'Head': 8, 'Body': 10, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 29 },
|
||||||
|
{ name: 'Tyranid Warrior', tb: 10, armourByLoc: { 'Head': 8, 'Body': 8, 'Left Arm': 8, 'Right Arm': 8, 'Left Leg': 8, 'Right Leg': 8 }, wounds: 48 },
|
||||||
|
{ name: 'Hormagaunt', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
|
||||||
|
{ name: 'Termagant', tb: 3, armourByLoc: { 'Head': 3, 'Body': 3, 'Left Arm': 3, 'Right Arm': 3, 'Left Leg': 3, 'Right Leg': 3 }, wounds: 9 },
|
||||||
|
{ name: 'Hive Tyrant', tb: 15, armourByLoc: { 'Head': 10, 'Body': 10, 'Left Arm': 10, 'Right Arm': 10, 'Left Leg': 10, 'Right Leg': 10 }, wounds: 120 },
|
||||||
|
{ name: 'Tau Commander (Crisis Suit)', tb: 10, armourByLoc: { 'Head': 9, 'Body': 9, 'Left Arm': 9, 'Right Arm': 9, 'Left Leg': 9, 'Right Leg': 9 }, wounds: 90 },
|
||||||
|
{ name: 'Industrial Servitor', tb: 5, armourByLoc: { 'Head': 7, 'Body': 7, 'Left Arm': 7, 'Right Arm': 7, 'Left Leg': 7, 'Right Leg': 7 }, wounds: 20 },
|
||||||
|
{ name: 'Ork Boy', tb: 5, armour: 3, wounds: 13 },
|
||||||
|
{ name: 'Ork Nob', tb: 6, armour: 4, wounds: 22 },
|
||||||
|
{ name: 'Genestealer', tb: 6, armour: 6, wounds: 22 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const THREAT_MODIFIERS = {
|
||||||
|
'Low': { enemyBonus: -10, sceneBonus: 0 },
|
||||||
|
'Medium': { enemyBonus: 0, sceneBonus: 0 },
|
||||||
|
'High': { enemyBonus: 10, sceneBonus: 1 },
|
||||||
|
'Extreme': { enemyBonus: 20, sceneBonus: 2 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate mission scenes
|
||||||
|
function generateMission() {
|
||||||
|
const newScenes = [];
|
||||||
|
const threatMod = THREAT_MODIFIERS[threat];
|
||||||
|
|
||||||
|
for (let i = 0; i < sceneCount; i++) {
|
||||||
|
const sceneType = SCENE_TYPES[i % SCENE_TYPES.length];
|
||||||
|
const sceneEnemies = [];
|
||||||
|
|
||||||
|
for (let j = 0; j < enemyCount; j++) {
|
||||||
|
const baseEnemy = ENEMY_TYPES.find(e => e.name === enemyType) || ENEMY_TYPES[1];
|
||||||
|
sceneEnemies.push({
|
||||||
|
...baseEnemy,
|
||||||
|
id: uid(),
|
||||||
|
currentWounds: baseEnemy.wounds,
|
||||||
|
maxWounds: baseEnemy.wounds,
|
||||||
|
name: `${baseEnemy.name} #${j + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newScenes.push({
|
||||||
|
id: uid(),
|
||||||
|
number: i + 1,
|
||||||
|
type: sceneType,
|
||||||
|
name: `${sceneType} ${i + 1}`,
|
||||||
|
enemies: sceneEnemies,
|
||||||
|
completed: false,
|
||||||
|
result: null,
|
||||||
|
threatMod: threatMod.sceneBonus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setScenes(newScenes);
|
||||||
|
setCurrentScene(0);
|
||||||
|
setCombatLog([]);
|
||||||
|
setSceneComplete(false);
|
||||||
|
setSceneResult(null);
|
||||||
|
setShowSetup(false);
|
||||||
|
setShowCombat(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run combat for current scene
|
||||||
|
function runCombat() {
|
||||||
|
const scene = scenes[currentScene];
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
const log = [];
|
||||||
|
let totalDamage = 0;
|
||||||
|
let enemiesDefeated = 0;
|
||||||
|
|
||||||
|
// Simulate combat rounds
|
||||||
|
for (let round = 1; round <= 3; round++) {
|
||||||
|
for (const enemy of scene.enemies) {
|
||||||
|
if (enemy.currentWounds <= 0) {
|
||||||
|
enemiesDefeated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player attacks enemy
|
||||||
|
const playerBS = 50 + Math.floor(Math.random() * 20);
|
||||||
|
const attack = d100();
|
||||||
|
const dg = degrees(playerBS, attack);
|
||||||
|
|
||||||
|
if (dg.success) {
|
||||||
|
const hits = hitsFromDoS('single', dg.dos, 1);
|
||||||
|
const damage = Math.max(0, Math.floor(Math.random() * 10) + 5 - enemy.armour);
|
||||||
|
enemy.currentWounds = Math.max(0, enemy.currentWounds - damage);
|
||||||
|
totalDamage += damage;
|
||||||
|
log.push(`Round ${round}: ${selectedPlayer} hits ${enemy.name} for ${damage} damage`);
|
||||||
|
|
||||||
|
if (enemy.currentWounds <= 0) {
|
||||||
|
enemiesDefeated++;
|
||||||
|
log.push(` ${enemy.name} defeated!`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.push(`Round ${round}: ${selectedPlayer} misses ${enemy.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCombatLog(log);
|
||||||
|
setSceneComplete(true);
|
||||||
|
setSceneResult({
|
||||||
|
damage: totalDamage,
|
||||||
|
enemiesDefeated,
|
||||||
|
totalEnemies: scene.enemies.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete scene
|
||||||
|
function completeScene() {
|
||||||
|
const newScenes = [...scenes];
|
||||||
|
newScenes[currentScene] = {
|
||||||
|
...newScenes[currentScene],
|
||||||
|
completed: true,
|
||||||
|
result: sceneResult
|
||||||
|
};
|
||||||
|
setScenes(newScenes);
|
||||||
|
|
||||||
|
if (currentScene < sceneCount - 1) {
|
||||||
|
setCurrentScene(currentScene + 1);
|
||||||
|
setSceneComplete(false);
|
||||||
|
setSceneResult(null);
|
||||||
|
setCombatLog([]);
|
||||||
|
} else {
|
||||||
|
setShowCombat(false);
|
||||||
|
setShowResults(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset mission
|
||||||
|
function resetMission() {
|
||||||
|
setShowSetup(true);
|
||||||
|
setShowCombat(false);
|
||||||
|
setShowResults(false);
|
||||||
|
setScenes([]);
|
||||||
|
setCurrentScene(0);
|
||||||
|
setCombatLog([]);
|
||||||
|
setSceneComplete(false);
|
||||||
|
setSceneResult(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authedPlayer) {
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Mission Simulation</h1>
|
||||||
|
<p className="text-slate-400">Please log in to access mission simulation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">Mission Simulation</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{showSetup && (
|
||||||
|
<button onClick={generateMission} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg">
|
||||||
|
Generate Mission
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showResults && (
|
||||||
|
<button onClick={resetMission} className="px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg">
|
||||||
|
New Mission
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup Panel */}
|
||||||
|
{showSetup && (
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6 space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Mission Setup</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Mission Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={missionName}
|
||||||
|
onChange={e => setMissionName(e.target.value)}
|
||||||
|
placeholder="Mission Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Scenes</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={sceneCount}
|
||||||
|
onChange={e => setSceneCount(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{[4, 5, 6].map(n => <option key={n} value={n}>{n} scenes</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Enemy Type</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={enemyType}
|
||||||
|
onChange={e => setEnemyType(e.target.value)}
|
||||||
|
>
|
||||||
|
{ENEMY_TYPES.map(e => <option key={e.name} value={e.name}>{e.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Enemy Count</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={enemyCount}
|
||||||
|
onChange={e => setEnemyCount(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Threat Level</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={threat}
|
||||||
|
onChange={e => setThreat(e.target.value)}
|
||||||
|
>
|
||||||
|
{THREAT_LEVELS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Player Count</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={playerCount}
|
||||||
|
onChange={e => setPlayerCount(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combat Panel */}
|
||||||
|
{showCombat && scenes.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Scene Progress */}
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
Scene {currentScene + 1}: {scenes[currentScene]?.name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{scenes.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${
|
||||||
|
i === currentScene ? 'bg-blue-600' :
|
||||||
|
s.completed ? 'bg-green-600' : 'bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.completed ? '✓' : i + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enemy List */}
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Enemies</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{scenes[currentScene]?.enemies.map(enemy => (
|
||||||
|
<div
|
||||||
|
key={enemy.id}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
enemy.currentWounds <= 0 ? 'bg-red-900/20 border-red-600' : 'bg-slate-700 border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{enemy.name}</span>
|
||||||
|
<span className="text-red-400">
|
||||||
|
{enemy.currentWounds}/{enemy.maxWounds} W
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-2 bg-slate-800 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-2 bg-red-500"
|
||||||
|
style={{ width: `${(enemy.currentWounds / enemy.maxWounds) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combat Controls */}
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Combat</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Attacker</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={selectedPlayer || ''}
|
||||||
|
onChange={e => setSelectedPlayer(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select Player</option>
|
||||||
|
{Array.from({ length: playerCount }, (_, i) => (
|
||||||
|
<option key={i} value={`Player ${i + 1}`}>Player {i + 1}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase opacity-70">Target</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2"
|
||||||
|
value={selectedEnemy || ''}
|
||||||
|
onChange={e => setSelectedEnemy(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select Enemy</option>
|
||||||
|
{scenes[currentScene]?.enemies.map(enemy => (
|
||||||
|
<option key={enemy.id} value={enemy.name} disabled={enemy.currentWounds <= 0}>
|
||||||
|
{enemy.name} {enemy.currentWounds <= 0 ? '(Defeated)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={runCombat}
|
||||||
|
disabled={!selectedPlayer || !selectedEnemy}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Run Combat
|
||||||
|
</button>
|
||||||
|
{sceneComplete && (
|
||||||
|
<button
|
||||||
|
onClick={completeScene}
|
||||||
|
className="px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg"
|
||||||
|
>
|
||||||
|
Complete Scene
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Combat Log */}
|
||||||
|
{combatLog.length > 0 && (
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Combat Log</h3>
|
||||||
|
<div className="space-y-1 text-sm font-mono">
|
||||||
|
{combatLog.map((entry, i) => (
|
||||||
|
<div key={i} className="text-slate-300">{entry}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Panel */}
|
||||||
|
{showResults && (
|
||||||
|
<div className="rounded-xl bg-slate-800 border border-slate-700 p-6">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Mission Complete</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-700 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-sm text-slate-300">Scenes</div>
|
||||||
|
<div className="text-2xl font-bold">{scenes.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-sm text-slate-300">Completed</div>
|
||||||
|
<div className="text-2xl font-bold text-green-400">
|
||||||
|
{scenes.filter(s => s.completed).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-sm text-slate-300">Total Damage</div>
|
||||||
|
<div className="text-2xl font-bold text-red-400">
|
||||||
|
{scenes.reduce((sum, s) => sum + (s.result?.damage || 0), 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700 rounded-lg p-4 text-center">
|
||||||
|
<div className="text-sm text-slate-300">Enemies Defeated</div>
|
||||||
|
<div className="text-2xl font-bold text-yellow-400">
|
||||||
|
{scenes.reduce((sum, s) => sum + (s.result?.enemiesDefeated || 0), 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MissionTab;
|
||||||
Reference in New Issue
Block a user