map controls and time

This commit is contained in:
2026-06-01 03:38:21 -04:00
parent 23577dbfd7
commit c7bc7e1681
2 changed files with 215 additions and 40 deletions
+75
View File
@@ -1079,6 +1079,81 @@ body {
background: #2a2a44; background: #2a2a44;
} }
.pass-time-panel {
position: absolute;
top: 60px;
left: 12px;
background: #1a1a2e;
border: 1px solid #5F71C5;
border-radius: 3px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 20;
min-width: 180px;
}
.pass-time-header {
color: #5F71C5;
font-size: 14px;
font-weight: bold;
}
.pass-time-slider-row {
display: flex;
align-items: center;
gap: 10px;
}
.pass-time-slider {
flex: 1;
accent-color: #5F71C5;
cursor: pointer;
}
.pass-time-label {
color: #e0e0e0;
font-size: 14px;
min-width: 30px;
text-align: right;
}
.pass-time-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.pass-time-confirm {
background: #5F71C5;
color: #fff;
border: none;
border-radius: 1px;
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
}
.pass-time-confirm:hover {
background: #4a5cb0;
}
.pass-time-cancel {
background: none;
border: 1px solid #2e303a;
color: #6b7280;
border-radius: 1px;
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
}
.pass-time-cancel:hover {
color: #e0e0e0;
border-color: #5F71C5;
}
.bottom-left-btns { .bottom-left-btns {
position: absolute; position: absolute;
bottom: 40px; bottom: 40px;
+140 -40
View File
@@ -253,6 +253,13 @@ function App() {
const [targetEnemy, setTargetEnemy] = useState(null) const [targetEnemy, setTargetEnemy] = useState(null)
const [attackEnemy, setAttackEnemy] = useState(null) const [attackEnemy, setAttackEnemy] = useState(null)
const [panelTarget, setPanelTarget] = useState('player') const [panelTarget, setPanelTarget] = useState('player')
const [passTimeOpen, setPassTimeOpen] = useState(false)
const [passTimeHours, setPassTimeHours] = useState(1)
const [skipAnim, setSkipAnim] = useState(null)
const [zoomAnim, setZoomAnim] = useState(null)
const passTimeRef = useRef(null)
const viewRef = useRef(null)
viewRef.current = view
const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 })
const minimapPanRef = useRef(false) const minimapPanRef = useRef(false)
const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapPanStart = useRef({ x: 0, y: 0 })
@@ -269,7 +276,7 @@ function App() {
return result return result
} }
const shopAtLoc = shops.find(s => s.locationId === selected) const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc)
const travel = (targetId) => { const travel = (targetId) => {
if (travelAnim) return if (travelAnim) return
@@ -306,63 +313,58 @@ function App() {
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const mx = e.clientX - rect.left const mx = e.clientX - rect.left
const my = e.clientY - rect.top const my = e.clientY - rect.top
const factor = e.deltaY > 0 ? 0.9 : 1.1 zoomTo(e.deltaY > 0 ? 1 / 1.3 : 1.3, mx, my)
setView(v => {
const ns = Math.max(0.3, Math.min(3, v.scale * factor))
const rx = mx / rect.width
const ry = my / rect.height
const nx = v.x + (rx * 800) / v.scale - (rx * 800) / ns
const ny = v.y + (ry * 600) / v.scale - (ry * 600) / ns
return { x: nx, y: ny, scale: ns }
})
} }
const handleBgPointerDown = (e) => { const handleBgPointerDown = (e) => {
if (e.button !== 0) return if (e.button !== 0) return
const v = viewRef.current
const rect = svgRef.current.getBoundingClientRect()
const s = Math.min(rect.width / 800, rect.height / 600)
const ox = (rect.width - 800 * s) / 2, oy = (rect.height - 600 * s) / 2
isPanning.current = true isPanning.current = true
panStart.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y } panStart.current = { x: e.clientX - ox, y: e.clientY - oy, vx: v.x, vy: v.y, s }
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
} }
const handleBgPointerMove = (e) => { const handleBgPointerMove = (e) => {
if (!isPanning.current) return if (!isPanning.current) return
const dx = (e.clientX - panStart.current.x) / view.scale const v = viewRef.current
const dy = (e.clientY - panStart.current.y) / view.scale const rect = svgRef.current.getBoundingClientRect()
setView(v => ({ ...v, x: panStart.current.vx - dx, y: panStart.current.vy - dy })) const s = Math.min(rect.width / 800, rect.height / 600)
const ox = (rect.width - 800 * s) / 2, oy = (rect.height - 600 * s) / 2
const mx = e.clientX - ox, my = e.clientY - oy
const px = panStart.current.x, py = panStart.current.y
const dx = (mx - px) / panStart.current.s / v.scale
const dy = (my - py) / panStart.current.s / v.scale
setView({ ...v, x: panStart.current.vx + dx, y: panStart.current.vy + dy })
} }
const handleBgPointerUp = () => { const handleBgPointerUp = () => {
isPanning.current = false isPanning.current = false
} }
const zoomTo = (factor, mx, my) => {
const v = viewRef.current
const rect = svgRef.current.getBoundingClientRect()
const s = Math.min(rect.width / 800, rect.height / 600)
const ox = (rect.width - 800 * s) / 2, oy = (rect.height - 600 * s) / 2
const vbX = (mx - ox) / s, vbY = (my - oy) / s
const ns = Math.max(0.3, Math.min(3, v.scale * factor))
const f = ns / v.scale
const nx = vbX * (1 - f) + v.x * f
const ny = vbY * (1 - f) + v.y * f
setZoomAnim({ from: { x: v.x, y: v.y, scale: v.scale }, to: { x: nx, y: ny, scale: ns }, start: performance.now() })
}
const zoomIn = () => { const zoomIn = () => {
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const mx = mousePos.x - rect.left zoomTo(1.3, rect.width / 2, rect.height / 2)
const my = mousePos.y - rect.top
const factor = 1.3
setView(v => {
const ns = Math.max(0.3, Math.min(3, v.scale * factor))
const rx = mx / rect.width
const ry = my / rect.height
const nx = v.x + (rx * 800) / v.scale - (rx * 800) / ns
const ny = v.y + (ry * 600) / v.scale - (ry * 600) / ns
return { x: nx, y: ny, scale: ns }
})
} }
const zoomOut = () => { const zoomOut = () => {
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const mx = mousePos.x - rect.left zoomTo(1 / 1.3, rect.width / 2, rect.height / 2)
const my = mousePos.y - rect.top
const factor = 1 / 1.3
setView(v => {
const ns = Math.max(0.3, Math.min(3, v.scale * factor))
const rx = mx / rect.width
const ry = my / rect.height
const nx = v.x + (rx * 800) / v.scale - (rx * 800) / ns
const ny = v.y + (ry * 600) / v.scale - (ry * 600) / ns
return { x: nx, y: ny, scale: ns }
})
} }
const makeEnemy = (npc, index) => { const makeEnemy = (npc, index) => {
@@ -717,6 +719,64 @@ function App() {
} }
}, [combatTime, combat, bodyInjuries]) }, [combatTime, combat, bodyInjuries])
useEffect(() => {
if (combat) return
const interval = setInterval(() => {
setGameTime(gt => {
let h = gt.hour + 1
let d = gt.day
if (h >= 24) { h = 0; d++ }
return { day: d, hour: h }
})
}, 5000)
return () => clearInterval(interval)
}, [combat])
useEffect(() => {
if (combat) return
const interval = setInterval(() => {
const types = ['clear', 'clear', 'cloudy', 'cloudy', 'rain', 'storm']
setWeather(types[Math.floor(Math.random() * types.length)])
}, 30000)
return () => clearInterval(interval)
}, [combat])
useEffect(() => {
if (!skipAnim) return
const duration = (skipAnim.target - skipAnim.current) * 2000
const frame = requestAnimationFrame(function tick() {
const elapsed = performance.now() - skipAnim.start
const t = Math.min(elapsed / duration, 1)
const current = skipAnim.current + (skipAnim.target - skipAnim.current) * t
if (t >= 1) {
setSkipAnim(null)
} else {
setSkipAnim(s => ({ ...s, current }))
requestAnimationFrame(tick)
}
})
return () => cancelAnimationFrame(frame)
}, [skipAnim])
useEffect(() => {
if (!zoomAnim) return
const duration = 300
const frame = requestAnimationFrame(function tick() {
const t = Math.min((performance.now() - zoomAnim.start) / duration, 1)
const ease = 1 - Math.pow(1 - t, 3)
const x = zoomAnim.from.x + (zoomAnim.to.x - zoomAnim.from.x) * ease
const y = zoomAnim.from.y + (zoomAnim.to.y - zoomAnim.from.y) * ease
const scale = zoomAnim.from.scale + (zoomAnim.to.scale - zoomAnim.from.scale) * ease
setView({ x, y, scale })
if (t >= 1) {
setZoomAnim(null)
} else {
requestAnimationFrame(tick)
}
})
return () => cancelAnimationFrame(frame)
}, [zoomAnim])
const handleItemClick = (itemId, i) => { const handleItemClick = (itemId, i) => {
setShowInventory(true); setPanelTarget('player') setShowInventory(true); setPanelTarget('player')
} }
@@ -744,6 +804,10 @@ function App() {
setAttackEnemy(null) setAttackEnemy(null)
} }
const skipTotalHours = skipAnim !== null ? skipAnim.current : null
const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day
const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour
return ( return (
<div className="game-map"> <div className="game-map">
{combat && ( {combat && (
@@ -1185,6 +1249,7 @@ function App() {
<g style={{ <g style={{
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`, transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
transformOrigin: '0 0', transformOrigin: '0 0',
willChange: 'transform',
}}> }}>
{connections.map((c, i) => { {connections.map((c, i) => {
const from = locMap[c.from] const from = locMap[c.from]
@@ -1234,6 +1299,12 @@ function App() {
<div className="top-bar"> <div className="top-bar">
<div className="top-bar-left"> <div className="top-bar-left">
<button className="icon-btn" onClick={() => { setShowInventory(true); setPanelTarget('player') }} title="Character">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 22a8 8 0 0 1 16 0" />
</svg>
</button>
<button className="icon-btn" onClick={() => { setShowWorldInfo(s => !s) }} title="World"> <button className="icon-btn" onClick={() => { setShowWorldInfo(s => !s) }} title="World">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
@@ -1241,10 +1312,10 @@ function App() {
<path d="M2 12h20" /> <path d="M2 12h20" />
</svg> </svg>
</button> </button>
<button className="icon-btn" onClick={() => { setShowInventory(true); setPanelTarget('player') }} title="Character"> <button className="icon-btn" onClick={() => { setPassTimeHours(1); setPassTimeOpen(s => !s) }} title="Pass Time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" /> <circle cx="12" cy="12" r="10" />
<path d="M4 22a8 8 0 0 1 16 0" /> <polyline points="12 6 12 12 16 14" />
</svg> </svg>
</button> </button>
<button className="icon-btn" onClick={() => setShowSettings(s => !s)} title="Settings"> <button className="icon-btn" onClick={() => setShowSettings(s => !s)} title="Settings">
@@ -1254,7 +1325,7 @@ function App() {
</button> </button>
</div> </div>
<div className="top-bar-right"> <div className="top-bar-right">
<div className="time-display">Day {gameTime.day} {String(gameTime.hour).padStart(2, '0')}:00</div> <div className="time-display">Day {displayDay} {String(displayHour).padStart(2, '0')}:00</div>
<div className="weather-display">{weatherLabels[weather]}</div> <div className="weather-display">{weatherLabels[weather]}</div>
</div> </div>
</div> </div>
@@ -1263,6 +1334,35 @@ function App() {
<button onClick={zoomIn} title="Zoom in">+</button> <button onClick={zoomIn} title="Zoom in">+</button>
<button onClick={zoomOut} title="Zoom out"></button> <button onClick={zoomOut} title="Zoom out"></button>
</div> </div>
{passTimeOpen && (
<div className="pass-time-panel">
<div className="pass-time-header">Pass Time</div>
<div className="pass-time-slider-row">
<input type="range" min="1" max="24" value={passTimeHours}
disabled={passTimeActive}
onChange={e => setPassTimeHours(Number(e.target.value))}
className="pass-time-slider" />
<span className="pass-time-label">{passTimeHours}h</span>
</div>
<div className="pass-time-btns">
<button className="pass-time-confirm" disabled={skipAnim !== null} onClick={() => {
const totalH = gameTime.day * 24 + gameTime.hour
const addH = passTimeHours
const targetH = totalH + addH
setGameTime(gt => {
let h = gt.hour + addH
let d = gt.day
if (h >= 24) { d += Math.floor(h / 24); h = h % 24 }
return { day: d, hour: h }
})
setSkipAnim({ current: totalH, target: targetH, start: performance.now() })
setPassTimeOpen(false)
}}>Skip</button>
<button className="pass-time-cancel" onClick={() => setPassTimeOpen(false)}>Cancel</button>
</div>
</div>
)}
<div className="bottom-left-btns"> <div className="bottom-left-btns">
<button className="fight-rand-btn" onClick={() => { <button className="fight-rand-btn" onClick={() => {
const npcs = liveCharacters.filter(c => c.id !== 'player') const npcs = liveCharacters.filter(c => c.id !== 'player')