map controls and time
This commit is contained in:
+75
@@ -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
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user