diff --git a/src/App.css b/src/App.css index 04c9483..293cea4 100644 --- a/src/App.css +++ b/src/App.css @@ -2478,6 +2478,28 @@ body { min-width: 20px; text-align: center; } +.trade-qty-input { + width: 40px; + height: 22px; + border: 1px solid #4a4a6a; + border-radius: 3px; + background: #16162a; + color: #e0e0e0; + font-size: 12px; + text-align: center; + outline: none; +} +.trade-qty-input:focus { + border-color: #6366f1; +} +.trade-qty-input::-webkit-inner-spin-button, +.trade-qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.trade-qty-input[type='number'] { + -moz-appearance: textfield; +} .trade-bar { margin-top: 12px; padding: 10px 12px; @@ -2504,3 +2526,41 @@ body { .trade-propose-btn:hover { background: #4b5563; } + +.notifications-container { + position: fixed; + bottom: 12px; + right: 12px; + display: flex; + flex-direction: column; + gap: 6px; + z-index: 9999; + pointer-events: none; +} +.notification { + width: 220px; + min-height: 56px; + background: #1e1e2e; + border: 1px solid #4a4a6a; + border-radius: 4px; + padding: 8px 14px; + font-size: 13px; + color: #e0e0e0; + animation: notif-in 0.2s ease-out; + display: flex; + flex-direction: column; + gap: 4px; +} +.notification-subject { + font-weight: bold; + font-size: 13px; + color: #fbbf24; +} +.notification-desc { + font-size: 12px; + color: #9ca3af; +} +@keyframes notif-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} diff --git a/src/App.jsx b/src/App.jsx index 329ea28..5f30a72 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,11 +32,12 @@ const characters = [ { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 1 }, { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 }, { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, + { id: 'e1', name: 'Zephyr', location: 'E', money: 9999, inventory: ['pistol', 'pistol', 'steel_sword', 'iron_shield', 'dagger', 'warhammer', 'rusty_sword', 'pistol_ammo', 'bouncing_ammo'], itemCounts: { pistol_ammo: 20, bouncing_ammo: 5 }, size: 1 }, ] const weapons = [ { id: 'fists', name: 'Fists', damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] }, - { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }] }, + { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, compatibleBulletSizes: ['9mm'], skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }] }, { id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }] }, { id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }] }, { id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }] }, @@ -45,8 +46,8 @@ const weapons = [ ] const items = [ - { id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5 }, - { id: 'bouncing_ammo', name: 'Bouncing Ammo', description: 'Experimental ammo that bounces off walls.', weight: 0.5 }, + { id: 'pistol_ammo', name: 'Pistol Ammo', description: 'Standard pistol ammunition.', weight: 0.5, bulletSize: '9mm' }, + { id: 'bouncing_ammo', name: 'Bouncing Ammo', description: 'Experimental ammo that bounces off walls.', weight: 0.5, bulletSize: '9mm' }, ] const itemMap = {} @@ -97,6 +98,7 @@ function getItemValue(id) { const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 +const CURRENCY = 'ahn' const PISTOL_MAG = 7 const bodyPartLabels = { @@ -593,6 +595,22 @@ export default function App() { const [fightRecord, setFightRecord] = useState(null) const [replayActive, setReplayActive] = useState(false) + // ── notifications + const [notifications, setNotifications] = useState([]) + + useEffect(() => { + const id = setInterval(() => { + setNotifications(prev => [...prev, { id: Date.now(), subject: 'Test', description: 'This is a test notification.' }]) + }, 5000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (notifications.length === 0) return + const id = setTimeout(() => setNotifications(prev => prev.slice(1)), 4000) + return () => clearTimeout(id) + }, [notifications]) + // ── menu const [showMenu, setShowMenu] = useState(true) const [showCharCreate, setShowCharCreate] = useState(false) @@ -733,6 +751,7 @@ export default function App() { const subSteps = Math.max(1, Math.ceil(moveDist)) let hit = false + let hitCx = null, hitCy = null for (let s = 1; s <= subSteps; s++) { const t = s / subSteps @@ -759,7 +778,7 @@ export default function App() { break } applyProjectileHit(p, null) - hit = true; break + hit = true; hitCx = cx; hitCy = cy; break } for (const cid of Object.keys(cc.characters)) { @@ -770,12 +789,16 @@ export default function App() { const r = cfg.hitRadius + 4 * (e.size ?? 1) if (Math.sqrt((cx - ePos.x) ** 2 + (cy - ePos.y) ** 2) < r) { applyProjectileHit(p, cid) - hit = true; break + hit = true; hitCx = cx; hitCy = cy; break } } if (hit) break } - if (hit) continue + if (hit) { + if (hitCx != null) { p.pos.x = hitCx; p.pos.y = hitCy } + out.push(p) + continue + } if (moveDist < stepDist) continue @@ -1764,7 +1787,7 @@ class MoveAction { equipped: { left: 'fists', right: 'fists' }, weaponConditions: {}, weaponMagazines: {}, - itemCounts: {}, + itemCounts: { ...(lc.itemCounts ?? {}) }, inventory: [...(lc.inventory ?? [])], fillColor: lc.fillColor, }) @@ -1971,7 +1994,7 @@ class MoveAction { } const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) const available = Object.entries(itemCounts) - .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + .filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize)) if (available.length === 0) { setCombat(c => ({ ...c, log: [...c.log, 'No ammo in inventory!'] })) return @@ -2198,9 +2221,9 @@ class MoveAction { if (!combat || charId === 'player') return true const src = combat.characters?.player const tgt = combat.characters?.[charId] - if (!src || !tgt) return true - const dx = src.x - tgt.x - const dy = src.y - tgt.y + if (!src?.pos || !tgt?.pos) return true + const dx = src.pos.x - tgt.pos.x + const dy = src.pos.y - tgt.pos.y return Math.sqrt(dx * dx + dy * dy) <= 35 } @@ -2811,11 +2834,11 @@ class MoveAction {
Select Ammo Type
{Object.entries(actorChar?.itemCounts ?? {}) - .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + .filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize)) .length === 0 ?
No ammo available
: Object.entries(actorChar?.itemCounts ?? {}) - .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) + .filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize)) .map(([id, qty]) => (
e.currentTarget.style.background = '#1e1e30'} @@ -3016,7 +3039,7 @@ class MoveAction {
{ e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: c.id }) }}>
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
- {c.money !== undefined && {c.money}g} + {c.money !== undefined && {c.money} {CURRENCY}} {c.inventory?.length > 0 && {c.inventory.map(id => itemMap[id]?.name ?? id).join(', ')}}
@@ -3145,7 +3168,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul }) } {isPlayer &&
Weight: {charWeight}/{MAX_WEIGHT}
} -
{character.money || 0}g
+
{character.money || 0} {CURRENCY}
)} {character && invTab === 'stats' && ( @@ -3160,7 +3183,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
Integrity{charHp}/{MAX_HP}
Items{character.inventory.length}
{isPlayer &&
Weight{charWeight}/{MAX_WEIGHT}
} -
Money{character.money || 0}g
+
Money{character.money || 0} {CURRENCY}
)} @@ -3187,7 +3210,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
Player
Location{locMap[player.location]?.name ?? player.location}
-
Money{player.money}g
+
Money{player.money} {CURRENCY}
@@ -3249,7 +3272,12 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul const items = [] for (const id of (ch.inventory ?? [])) { if (seen.has(id)) continue; seen.add(id) - items.push({ id, name: weaponMap[id]?.name ?? itemMap[id]?.name ?? id, qty: 1, value: getItemValue(id), cond: ch.weaponConditions?.[id] ?? weaponMap[id]?.maxCondition ?? null, maxCond: weaponMap[id]?.maxCondition ?? null }) + const stacked = ch.itemCounts?.[id] + if (stacked !== undefined) { + items.push({ id, name: itemMap[id]?.name ?? id, qty: stacked, value: getItemValue(id) }) + } else { + items.push({ id, name: weaponMap[id]?.name ?? itemMap[id]?.name ?? id, qty: 1, value: getItemValue(id), cond: ch.weaponConditions?.[id] ?? weaponMap[id]?.maxCondition ?? null, maxCond: weaponMap[id]?.maxCondition ?? null }) + } } for (const [id, qty] of Object.entries(ch.itemCounts ?? {})) { if (qty > 0) { @@ -3276,7 +3304,6 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul } for (const [id, qty] of giveEntries) { if (weaponMap[id]) { - // weapon: remove from player inventory, give to target newPlayer.inventory = newPlayer.inventory.filter(i => i !== id) if (tgtWorld) { tgtWorld.inventory = tgtWorld.inventory ?? [] @@ -3286,14 +3313,13 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul delete newPlayer.weaponConditions[id] } } - } else if (itemMap[id]) { - // ammo/item: reduce player count, increase target count + } else { newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) - qty if (newPlayer.itemCounts[id] <= 0) delete newPlayer.itemCounts[id] if (tgtWorld) { const tgt = tgtWorld - tgt.inventory = [...(tgt.inventory ?? []), id] + if (!tgt.inventory.includes(id)) tgt.inventory = [...(tgt.inventory ?? []), id] tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) + qty } @@ -3301,7 +3327,6 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul } for (const [id, qty] of takeEntries) { if (weaponMap[id]) { - // weapon: remove from target inventory, give to player if (tgtWorld) { tgtWorld.inventory = tgtWorld.inventory.filter(i => i !== id) if (tgtWorld.weaponConditions?.[id] != null) { @@ -3311,20 +3336,32 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul } } newPlayer.inventory = [...(newPlayer.inventory ?? []), id] - } else if (itemMap[id]) { - // ammo/item: reduce target count (from itemCounts and/or inventory) + } else { if (tgtWorld) { const tgt = tgtWorld - tgt.inventory = (tgt.inventory ?? []).filter(i => i !== id) tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) - qty - if (tgt.itemCounts[id] <= 0) delete tgt.itemCounts[id] + if (tgt.itemCounts[id] <= 0) { + delete tgt.itemCounts[id] + tgt.inventory = (tgt.inventory ?? []).filter(i => i !== id) + } } + if (!newPlayer.inventory.includes(id)) newPlayer.inventory = [...newPlayer.inventory, id] newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) + qty } } setPlayer(newPlayer) + if (combat) { + setCombat(prev => { + const chars = { ...prev.characters } + chars.player = { ...chars.player, inventory: [...newPlayer.inventory], itemCounts: { ...newPlayer.itemCounts }, weaponConditions: { ...newPlayer.weaponConditions } } + if (tradeTarget && chars[tradeTarget] && tgtWorld) { + chars[tradeTarget] = { ...chars[tradeTarget], inventory: [...tgtWorld.inventory], itemCounts: { ...tgtWorld.itemCounts }, weaponConditions: { ...tgtWorld.weaponConditions } } + } + return { ...prev, log: [...prev.log, 'Trade completed.'], characters: chars } + }) + } setShowTrade(false) setTradeTarget(null) setTradeGive({}) @@ -3344,7 +3381,15 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul {item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}
- {tradeGive[item.id] ?? 0} + {item.cond != null ? ( + {tradeGive[item.id] ?? 0} + ) : ( + { + const v = parseInt(e.target.value, 10) + setTradeGive(prev => ({ ...prev, [item.id]: isNaN(v) ? 0 : Math.max(0, Math.min(item.qty, v)) })) + }} /> + )}
@@ -3359,7 +3404,15 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul {item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}
- {tradeTake[item.id] ?? 0} + {item.cond != null ? ( + {tradeTake[item.id] ?? 0} + ) : ( + { + const v = parseInt(e.target.value, 10) + setTradeTake(prev => ({ ...prev, [item.id]: isNaN(v) ? 0 : Math.max(0, Math.min(item.qty, v)) })) + }} /> + )}
@@ -3368,7 +3421,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
- Give: {giveVal}g   Take: {takeVal}g + Give: {giveVal} {CURRENCY}   Take: {takeVal} {CURRENCY}
@@ -3409,6 +3462,16 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul )} + + {/* ═══════════════════════════════════════════════════════ NOTIFICATIONS */} +
+ {notifications.map(n => ( +
+
{n.subject}
+
{n.description}
+
+ ))} +
) } \ No newline at end of file