diff --git a/src/App.css b/src/App.css index eeafedb..310694b 100644 --- a/src/App.css +++ b/src/App.css @@ -1079,6 +1079,81 @@ body { 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 { position: absolute; bottom: 40px; diff --git a/src/App.jsx b/src/App.jsx index 3fe49e1..8c81a99 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -253,6 +253,13 @@ function App() { const [targetEnemy, setTargetEnemy] = useState(null) const [attackEnemy, setAttackEnemy] = useState(null) 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 minimapPanRef = useRef(false) const minimapPanStart = useRef({ x: 0, y: 0 }) @@ -269,7 +276,7 @@ function App() { return result } - const shopAtLoc = shops.find(s => s.locationId === selected) + const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc) const travel = (targetId) => { if (travelAnim) return @@ -306,63 +313,58 @@ function App() { const rect = svgRef.current.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top - const factor = e.deltaY > 0 ? 0.9 : 1.1 - 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 } - }) + zoomTo(e.deltaY > 0 ? 1 / 1.3 : 1.3, mx, my) } const handleBgPointerDown = (e) => { 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 - 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) } const handleBgPointerMove = (e) => { if (!isPanning.current) return - const dx = (e.clientX - panStart.current.x) / view.scale - const dy = (e.clientY - panStart.current.y) / view.scale - setView(v => ({ ...v, x: panStart.current.vx - dx, y: panStart.current.vy - dy })) + 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 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 = () => { 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 rect = svgRef.current.getBoundingClientRect() - const mx = mousePos.x - rect.left - 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 } - }) + zoomTo(1.3, rect.width / 2, rect.height / 2) } const zoomOut = () => { const rect = svgRef.current.getBoundingClientRect() - const mx = mousePos.x - rect.left - 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 } - }) + zoomTo(1 / 1.3, rect.width / 2, rect.height / 2) } const makeEnemy = (npc, index) => { @@ -717,6 +719,64 @@ function App() { } }, [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) => { setShowInventory(true); setPanelTarget('player') } @@ -744,6 +804,10 @@ function App() { 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 (