diff --git a/src/controls.ts b/src/controls.ts index a3a22029..8df3f209 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -51,6 +51,7 @@ export const contro = new ControMax({ }, ui: { back: [null/* 'Escape' */, 'B'], + toggleMap: ['KeyM'], leftClick: [null, 'A'], rightClick: [null, 'Y'], speedupCursor: [null, 'Left Stick'], diff --git a/src/react/FullScreenMap.stories.tsx b/src/react/FullScreenMap.stories.tsx new file mode 100644 index 00000000..54b7084a --- /dev/null +++ b/src/react/FullScreenMap.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import FullScreenMap from './FullScreenMap' + +const meta: Meta = { + component: FullScreenMap +} + +export default meta +type Story = StoryObj + +export const Primary: Story = { + args: { + }, + parameters: { + noScaling: true + }, +} diff --git a/src/react/FullScreenMap.tsx b/src/react/FullScreenMap.tsx new file mode 100644 index 00000000..a0366722 --- /dev/null +++ b/src/react/FullScreenMap.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { DragGesture, WheelGesture, ScrollGesture, MoveGesture, PinchGesture } from '@use-gesture/vanilla' +import Gesto from 'gesto' + +export default () => { + const ref = useRef(null) + + useEffect(() => { + const canvas = ref.current! + const { scene, camera, renderer, addCube, onDestroy } = initScene(canvas) + + // const size = 16 * 4 * 1 + const size = 10 + for (let x = -size / 2; x < size / 2; x++) { + for (let z = -size / 2; z < size / 2; z++) { + addCube(x, z) + } + } + return () => { + renderer.dispose() + onDestroy() + } + }, []) + + return +} + +const initScene = (canvas: HTMLCanvasElement) => { + const abortController = new AbortController() + + const renderer = new THREE.WebGLRenderer({ canvas }) + renderer.setSize(window.innerWidth, window.innerHeight) + renderer.setPixelRatio(window.devicePixelRatio || 1) + renderer.setClearColor(0x000000, 1) + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera(75, canvas.width / canvas.height, 0.1, 1000) + + camera.position.set(0, 80, 0) + // look down + camera.rotation.set(-Math.PI / 2, 0, 0, 'ZYX') + + + const onResize = () => { + renderer.setSize(window.innerWidth, window.innerHeight) + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + } + window.addEventListener('resize', onResize, { signal: abortController.signal }) + onResize() + + let debugText = 'test' + const debugTextEl = document.createElement('div') + debugTextEl.style.position = 'fixed' + debugTextEl.style.top = '0' + debugTextEl.style.left = '0' + debugTextEl.style.background = 'rgba(0, 0, 0, 0.5)' + debugTextEl.style.color = 'white' + debugTextEl.style.fontSize = '10px' + debugTextEl.style.padding = '5px' + document.body.appendChild(debugTextEl) + + renderer.setAnimationLoop(() => { + renderer.render(scene, camera) + debugTextEl.innerText = debugText + }) + + // register controls + + const gestures = [] as { destroy: () => void }[] + const gesture = new DragGesture(canvas, ({ movement: [mx, my] }) => { + camera.position.x -= mx * 0.001 + camera.position.z -= my * 0.001 + }) + const wheel = new WheelGesture(canvas, ({ delta: [dx, dy] }) => { + camera.position.y += dy * 0.01 + }) + const pinch = new PinchGesture(canvas, ({ delta, movement: [ox, oy], pinching, origin }) => { + console.log([ox, oy], delta, pinching, origin) + }) + gestures.push(wheel) + gestures.push(gesture) + + let scale = 1 + // const gesto = new Gesto(canvas, { + // container: window, + // pinchOutside: true, + // }).on('drag', ({ deltaX, deltaY }) => { + // camera.position.x -= deltaX * 0.01 + // camera.position.z -= deltaY * 0.01 + // }).on('pinchStart', (e) => { + // e.datas.scale = scale + // }).on('pinch', ({ datas: { scale: newScale } }) => { + // scale = newScale + // console.log(scale) + // camera.position.y += newScale * 0.01 + // }) + + + return { + scene, + camera, + renderer, + onDestroy: () => { + abortController.abort() + for (const gesture of gestures) { + gesture.destroy() + } + // gesto.unset() + }, + addCube (x, z) { + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) + const cube = new THREE.Mesh(geometry, material) + cube.position.set(x, 0, z) + scene.add(cube) + } + } +} + +document.addEventListener('gesturestart', (e) => e.preventDefault()) +document.addEventListener('gesturechange', (e) => e.preventDefault()) diff --git a/src/react/KeybindingsScreen.module.css b/src/react/KeybindingsScreen.module.css index e8a9f69b..102974ce 100644 --- a/src/react/KeybindingsScreen.module.css +++ b/src/react/KeybindingsScreen.module.css @@ -49,6 +49,7 @@ .undo-keyboard, .undo-gamepad { aspect-ratio: 1; + min-width: 20px; } .button { diff --git a/src/react/Minimap.stories.tsx b/src/react/Minimap.stories.tsx new file mode 100644 index 00000000..3ecdf9b5 --- /dev/null +++ b/src/react/Minimap.stories.tsx @@ -0,0 +1,66 @@ +import { Vec3 } from 'vec3' +import type { Meta, StoryObj } from '@storybook/react' +import { WorldWarp } from 'flying-squid/dist/lib/modules/warps' +import { TypedEventEmitter } from 'contro-max/build/typedEventEmitter' +import { useEffect } from 'react' + +import { cleanData } from 'cypress/types/jquery' +import Minimap from './Minimap' +import { DrawerAdapter, MapUpdates } from './MinimapDrawer' + +const meta: Meta = { + component: Minimap, + decorators: [ + (Story, context) => { + + useEffect(() => { + console.log('map updated') + adapter.emit('updateMap') + + }, [context.args['fullMap']]) + + return
+ } + ] +} + +export default meta +type Story = StoryObj; + + +class DrawerAdapterImpl extends TypedEventEmitter implements DrawerAdapter { + playerPosition: Vec3 + yaw: number + warps: WorldWarp[] + + constructor (pos?: Vec3, warps?: WorldWarp[]) { + super() + this.playerPosition = pos ?? new Vec3(0, 0, 0) + this.warps = warps ?? [] as WorldWarp[] + } + + getHighestBlockColor (x: number, z:number) { + console.log('got color') + return 'green' + } + + setWarp (name: string, pos: Vec3, color: string, disabled: boolean, world?: string): void { + const warp: WorldWarp = { name, x: pos.x, y: pos.y, z: pos.z, world, color, disabled } + const index = this.warps.findIndex(w => w.name === name) + if (index === -1) { + this.warps.push(warp) + } else { + this.warps[index] = warp + } + this.emit('updateWarps') + } +} + +const adapter = new DrawerAdapterImpl() + +export const Primary: Story = { + args: { + adapter, + fullMap: false + }, +} diff --git a/src/react/Minimap.tsx b/src/react/Minimap.tsx new file mode 100644 index 00000000..b33b1cc0 --- /dev/null +++ b/src/react/Minimap.tsx @@ -0,0 +1,264 @@ +import { useRef, useEffect, useState, CSSProperties, Dispatch, SetStateAction } from 'react' +import { Vec3 } from 'vec3' +import { WorldWarp } from 'flying-squid/dist/lib/modules/warps' +import { showModal, hideModal } from '../globalState' +import { useIsModalActive } from './utilsApp' +import { MinimapDrawer, DrawerAdapter } from './MinimapDrawer' +import Input from './Input' +import Button from './Button' + + +export default ({ adapter, fullMap }: { adapter: DrawerAdapter, fullMap?: boolean }) => { + const fullMapOpened = useIsModalActive('full-map') + const [isWarpInfoOpened, setIsWarpInfoOpened] = useState(false) + const canvasTick = useRef(0) + const canvasRef = useRef(null) + const drawerRef = useRef(null) + + function updateMap () { + if (drawerRef.current && canvasTick.current % 2 === 0) { + drawerRef.current.draw(adapter.playerPosition) + if (canvasTick.current % 300 === 0) { + drawerRef.current.deleteOldWorldColors(adapter.playerPosition.x, adapter.playerPosition.z) + } + } + canvasTick.current += 1 + } + + const toggleFullMap = () => { + if (fullMapOpened) { + hideModal({ reactType: 'full-map' }) + } else { + showModal({ reactType: 'full-map' }) + } + } + + const handleClickOnMap = (e: MouseEvent) => { + drawerRef.current?.setWarpPosOnClick(e, adapter.playerPosition) + setIsWarpInfoOpened(true) + } + + const updateWarps = () => { + + } + + useEffect(() => { + if (canvasRef.current && !drawerRef.current) { + drawerRef.current = new MinimapDrawer(canvasRef.current, adapter) + } else if (canvasRef.current && drawerRef.current) { + drawerRef.current.canvas = canvasRef.current + } + }, [canvasRef.current, fullMapOpened, fullMap]) + + useEffect(() => { + if ((fullMapOpened || fullMap) && canvasRef.current) { + canvasRef.current.addEventListener('click', handleClickOnMap) + } else if (!fullMapOpened || !fullMap) { + setIsWarpInfoOpened(false) + } + + return () => { + canvasRef.current?.removeEventListener('click', handleClickOnMap) + } + }, [fullMapOpened, fullMap]) + + useEffect(() => { + adapter.on('updateMap', updateMap) + adapter.on('toggleFullMap', toggleFullMap) + adapter.on('updateWaprs', updateWarps) + + return () => { + adapter.off('updateMap', updateMap) + adapter.off('toggleFullMap', toggleFullMap) + adapter.off('updateWaprs', updateWarps) + } + }, [adapter]) + + return fullMapOpened || fullMap ?
+
{ + toggleFullMap() + }} + >
+ + + {isWarpInfoOpened && } +
:
{ + toggleFullMap() + }} + > + +
+} + +const WarpInfo = ( + { adapter, drawer, setIsWarpInfoOpened } + : + { adapter: DrawerAdapter, drawer: MinimapDrawer | null, setIsWarpInfoOpened: Dispatch> } +) => { + const [warp, setWarp] = useState({ + name: '', + x: drawer?.lastWarpPos.x ?? 100, + y: drawer?.lastWarpPos.y ?? 100, + z: drawer?.lastWarpPos.z ?? 100, + color: '#d3d3d3', + disabled: false, + world: adapter.world + }) + + const posInputStyle: CSSProperties = { + flexGrow: '1', + } + const fieldCont: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: '5px' + } + + return
+
+
+
+ Name: +
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, name: e.target.value } }) + }} + /> +
+
+
+ X: +
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, x: Number(e.target.value) } }) + }} + /> +
+ Y: +
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, y: Number(e.target.value) } }) + }} + /> +
+ Z: +
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, z: Number(e.target.value) } }) + }} + /> +
+
+
Color:
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, color: e.target.value } }) + }} + /> +
+
+
Disabled:
+ { + if (!e.target) return + setWarp(prev => { return { ...prev, disabled: e.target.checked } }) + }} + /> +
+
+ + +
+
+
+} diff --git a/src/react/MinimapDrawer.ts b/src/react/MinimapDrawer.ts new file mode 100644 index 00000000..eddc338e --- /dev/null +++ b/src/react/MinimapDrawer.ts @@ -0,0 +1,268 @@ +import { Vec3 } from 'vec3' +import { TypedEventEmitter } from 'contro-max/build/typedEventEmitter' +import { WorldWarp } from 'flying-squid/dist/lib/modules/warps' + +type BotType = Omit & { + world: Omit & { + getBlock: (pos: import('vec3').Vec3) => import('prismarine-block').Block | null + } + _client: Omit & { + write: typeof import('../generatedClientPackets').clientWrite + on: typeof import('../generatedServerPackets').clientOn + } +} + +export type MapUpdates = { + updateBlockColor: (pos: Vec3) => void + updatePlayerPosition: () => void + updateWarps: () => void +} + +export interface DrawerAdapter extends TypedEventEmitter { + getHighestBlockColor: (x: number, z: number) => string + playerPosition: Vec3 + warps: WorldWarp[] + world?: string + yaw: number + setWarp: (name: string, pos: Vec3, color: string, disabled: boolean, world?: string) => void +} + +export class MinimapDrawer { + centerX: number + centerY: number + _mapSize: number + radius: number + ctx: CanvasRenderingContext2D + _canvas: HTMLCanvasElement + worldColors: { [key: string]: string } = {} + lastBotPos: Vec3 + lastWarpPos: Vec3 + mapPixel: number + + constructor ( + canvas: HTMLCanvasElement, + public adapter: DrawerAdapter + ) { + this.canvas = canvas + this.adapter = adapter + } + + get canvas () { + return this._canvas + } + + set canvas (canvas: HTMLCanvasElement) { + this.ctx = canvas.getContext('2d', { willReadFrequently: true })! + this.ctx.imageSmoothingEnabled = false + this.radius = Math.floor(Math.min(canvas.width, canvas.height) / 2.2) + this._mapSize = this.radius * 2 + this.mapPixel = Math.floor(this.radius * 2 / this.mapSize) + this.centerX = canvas.width / 2 + this.centerY = canvas.height / 2 + this._canvas = canvas + } + + get mapSize () { + return this._mapSize + } + + set mapSize (mapSize: number) { + this._mapSize = mapSize + this.mapPixel = Math.floor(this.radius * 2 / this.mapSize) + this.draw(this.lastBotPos) + } + + draw ( + botPos: Vec3, + getHighestBlockColor?: DrawerAdapter['getHighestBlockColor'], + ) { + this.ctx.clearRect( + this.centerX - this.radius, + this.centerY - this.radius, + this.radius * 2, + this.radius * 2 + ) + + this.lastBotPos = botPos + this.updateWorldColors(getHighestBlockColor ?? this.adapter.getHighestBlockColor, botPos.x, botPos.z) + this.drawWarps() + this.rotateMap() + this.drawPartsOfWorld() + } + + updateWorldColors ( + getHighestBlockColor: DrawerAdapter['getHighestBlockColor'], + x: number, + z: number + ) { + const left = this.centerX - this.radius + const top = this.centerY - this.radius + + this.ctx.save() + + this.ctx.beginPath() + this.ctx.arc(this.centerX, this.centerY, this.radius, 0, Math.PI * 2, true) + this.ctx.clip() + + for (let row = 0; row < this.mapSize; row += 1) { + for (let col = 0; col < this.mapSize; col += 1) { + this.ctx.fillStyle = this.getHighestBlockColorCached( + getHighestBlockColor, + x - this.mapSize / 2 + col, + z - this.mapSize / 2 + row + ) + this.ctx.fillRect( + left + this.mapPixel * col, + top + this.mapPixel * row, + this.mapPixel, + this.mapPixel + ) + } + } + + const clippedImage = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) + this.ctx.restore() + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.ctx.putImageData(clippedImage, 0, 0) + } + + getHighestBlockColorCached ( + getHighestBlockColor: DrawerAdapter['getHighestBlockColor'], + x: number, + z: number + ) { + const roundX = Math.floor(x) + const roundZ = Math.floor(z) + const key = `${roundX},${roundZ}` + if (this.worldColors[key]) { + return this.worldColors[key] + } + const color = getHighestBlockColor(x, z) + if (color !== 'white') this.worldColors[key] = color + return color + } + + getDistance (x1: number, z1: number, x2: number, z2: number): number { + return Math.hypot((x2 - x1), (z2 - z1)) + } + + deleteOldWorldColors (currX: number, currZ: number) { + for (const key of Object.keys(this.worldColors)) { + const [x, z] = key.split(',').map(Number) + if (this.getDistance(x, z, currX, currZ) > this.radius * 5) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.worldColors[`${x},${z}`] + } + } + } + + setWarpPosOnClick (e: MouseEvent, botPos: Vec3) { + if (!e.target) return + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect() + const z = (e.pageY - rect.top) * this.canvas.width / rect.width + const x = (e.pageX - rect.left) * this.canvas.height / rect.height + const worldX = x - this.mapSize / 2 + const worldZ = z - this.mapSize / 2 + + // console.log([(botPos.x + worldX).toFixed(0), (botPos.z + worldZ).toFixed(0)]) + this.lastWarpPos = new Vec3(Math.floor(botPos.x + worldX), botPos.y, Math.floor(botPos.z + worldZ)) + } + + drawWarps () { + for (const warp of this.adapter.warps) { + const distance = this.getDistance( + this.adapter.playerPosition.x, + this.adapter.playerPosition.z, + warp.x, + warp.z + ) + if (distance > this.mapSize) continue + const z = Math.floor((this.mapSize / 2 - this.adapter.playerPosition.z + warp.z)) + const x = Math.floor((this.mapSize / 2 - this.adapter.playerPosition.x + warp.x)) + const dz = z - this.centerX + const dx = x - this.centerY + const circleDist = Math.hypot(dx, dz) + + const angle = Math.atan2(dz, dx) + const circleZ = circleDist > this.mapSize / 2 ? this.centerX + this.mapSize / 2 * Math.sin(angle) : z + const circleX = circleDist > this.mapSize / 2 ? this.centerY + this.mapSize / 2 * Math.cos(angle) : x + this.ctx.beginPath() + this.ctx.arc(circleX, circleZ, circleDist > this.mapSize / 2 ? 1.5 : 2, 0, Math.PI * 2, false) + this.ctx.strokeStyle = 'black' + this.ctx.lineWidth = 1 + this.ctx.stroke() + this.ctx.fillStyle = warp.disabled ? 'rgba(255, 255, 255, 0.4)' : warp.color ?? '#d3d3d3' + this.ctx.fill() + this.ctx.closePath() + } + } + + drawPartsOfWorld () { + this.ctx.fillStyle = 'white' + this.ctx.shadowOffsetX = 1 + this.ctx.shadowOffsetY = 1 + this.ctx.shadowColor = 'black' + this.ctx.font = `${this.radius / 4}px serif` + this.ctx.textAlign = 'center' + this.ctx.textBaseline = 'middle' + this.ctx.strokeStyle = 'black' + this.ctx.lineWidth = 1 + + const angle = this.adapter.yaw % Math.PI + const angleS = angle + Math.PI + const angleW = angle + Math.PI * 3 / 2 + const angleE = angle + Math.PI / 2 + + this.ctx.strokeText( + 'N', + this.centerX + this.radius * Math.cos(angle), + this.centerY + this.radius * Math.sin(angle) + ) + this.ctx.strokeText( + 'S', + this.centerX + this.radius * Math.cos(angleS), + this.centerY + this.radius * Math.sin(angleS) + ) + this.ctx.strokeText( + 'W', + this.centerX + this.radius * Math.cos(angleW), + this.centerY + this.radius * Math.sin(angleW) + ) + this.ctx.strokeText( + 'E', + this.centerX + this.radius * Math.cos(angleE), + this.centerY + this.radius * Math.sin(angleE) + ) + this.ctx.fillText( + 'N', + this.centerX + this.radius * Math.cos(angle), + this.centerY + this.radius * Math.sin(angle) + ) + this.ctx.fillText( + 'S', + this.centerX + this.radius * Math.cos(angleS), + this.centerY + this.radius * Math.sin(angleS) + ) + this.ctx.fillText( + 'W', + this.centerX + this.radius * Math.cos(angleW), + this.centerY + this.radius * Math.sin(angleW) + ) + this.ctx.fillText( + 'E', + this.centerX + this.radius * Math.cos(angleE), + this.centerY + this.radius * Math.sin(angleE) + ) + + this.ctx.shadowOffsetX = 0 + this.ctx.shadowOffsetY = 0 + } + + rotateMap () { + this.ctx.setTransform(1, 0, 0, 1, 0, 0) + const angle = this.adapter.yaw % Math.PI + this.ctx.translate(this.centerX, this.centerY) + this.ctx.rotate(angle) + this.ctx.translate(-this.centerX, -this.centerY) + } +} diff --git a/src/react/MinimapProvider.tsx b/src/react/MinimapProvider.tsx new file mode 100644 index 00000000..5e56e585 --- /dev/null +++ b/src/react/MinimapProvider.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' +import { Vec3 } from 'vec3' +import { WorldWarp } from 'flying-squid/dist/lib/modules/warps' +import { TypedEventEmitter } from 'contro-max/build/typedEventEmitter' +import BlockData from '../../prismarine-viewer/viewer/lib/moreBlockDataGenerated.json' +import { contro } from '../controls' +import Minimap from './Minimap' +import { DrawerAdapter, MapUpdates } from './MinimapDrawer' + +export class DrawerAdapterImpl extends TypedEventEmitter implements DrawerAdapter { + playerPosition: Vec3 + yaw: number + warps: WorldWarp[] + world: string + + constructor (pos?: Vec3, warps?: WorldWarp[]) { + super() + this.playerPosition = pos ?? new Vec3(0, 0, 0) + this.warps = warps ?? [] as WorldWarp[] + } + + getHighestBlockColor (x: number, z:number) { + let block = null as import('prismarine-block').Block | null + let { height } = (bot.game as any) + const airBlocks = new Set(['air', 'cave_air', 'void_air']) + do { + block = bot.world.getBlock(new Vec3(x, height, z)) + height -= 1 + } while (airBlocks.has(block?.name ?? '')) + const color = BlockData.colors[block?.name ?? ''] ?? 'white' + return color + } + + setWarp (name: string, pos: Vec3, color: string, disabled: boolean, world?: string): void { + this.world = bot.game.dimension + const warp: WorldWarp = { name, x: pos.x, y: pos.y, z: pos.z, world: world ?? this.world, color, disabled } + const index = this.warps.findIndex(w => w.name === name) + if (index === -1) { + this.warps.push(warp) + } else { + this.warps[index] = warp + } + // if (localServer) void localServer.setWarp(warp) + this.emit('updateWarps') + } +} + +export default () => { + const [adapter] = useState(() => new DrawerAdapterImpl(bot.entity.position, localServer?.warps)) + + const updateMap = () => { + if (!adapter) return + adapter.playerPosition = bot.entity.position + adapter.yaw = bot.entity.yaw + adapter.emit('updateMap') + } + + const toggleFullMap = ({ command }) => { + if (!adapter) return + if (command === 'ui.toggleMap') adapter.emit('toggleFullMap') + } + + useEffect(() => { + bot.on('move', updateMap) + + contro.on('trigger', toggleFullMap) + + return () => { + bot.off('move', updateMap) + contro.off('trigger', toggleFullMap) + } + }, []) + + return
+ +
+} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 34a567cd..a03141c5 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -19,6 +19,7 @@ import ScoreboardProvider from './react/ScoreboardProvider' import SignEditorProvider from './react/SignEditorProvider' import IndicatorEffectsProvider from './react/IndicatorEffectsProvider' import PlayerListOverlayProvider from './react/PlayerListOverlayProvider' +import MinimapProvider from './react/MinimapProvider' import HudBarsProvider from './react/HudBarsProvider' import XPBarProvider from './react/XPBarProvider' import DebugOverlay from './react/DebugOverlay' @@ -116,6 +117,7 @@ const InGameUi = () => { +