Skip to content

Commit

Permalink
feat: effects + system indicators in hud! (#95)
Browse files Browse the repository at this point in the history
Co-authored-by: gguio <[email protected]>
  • Loading branch information
gguio and gguio authored Mar 26, 2024
1 parent 7884b5c commit 07491fd
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 2 deletions.
1 change: 1 addition & 0 deletions prismarine-viewer/viewer/lib/worldrenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export class WorldRenderer {
}

setSectionDirty (pos, value = true) {
this.renderUpdateEmitter.emit('dirty', pos, value)
this.cleanChunkTextures(pos.x, pos.z) // todo don't do this!
// Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section
Expand Down
24 changes: 23 additions & 1 deletion src/browserfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,18 @@ fs.promises = new Proxy(Object.fromEntries(['readFile', 'writeFile', 'stat', 'mk
if (p === 'open' && fsState.isReadonly) {
args[1] = 'r' // read-only, zipfs throw otherwise
}
return target[p](...args)
if (p === 'readFile') {
fsState.openReadOperations++
} else if (p === 'writeFile') {
fsState.openWriteOperations++
}
return target[p](...args).finally(() => {
if (p === 'readFile') {
fsState.openReadOperations--
} else if (p === 'writeFile') {
fsState.openWriteOperations--
}
})
}
}
})
Expand All @@ -77,7 +88,18 @@ fs.promises.open = async (...args) => {
return
}

if (x === 'read') {
fsState.openReadOperations++
} else if (x === 'write' || x === 'close') {
fsState.openWriteOperations++
}
fs[x](fd, ...args, (err, bytesRead, buffer) => {
if (x === 'read') {
fsState.openReadOperations--
} else if (x === 'write' || x === 'close') {
// todo that's not correct
fsState.openWriteOperations--
}
if (err) throw err
// todo if readonly probably there is no need to open at all (return some mocked version - check reload)?
if (x === 'write' && !fsState.isReadonly) {
Expand Down
1 change: 1 addition & 0 deletions src/globalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export type AppConfig = {
export const miscUiState = proxy({
currentDisplayQr: null as string | null,
currentTouch: null as boolean | null,
hasErrors: false,
singleplayer: false,
flyingSquid: false,
wanOpened: false,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ async function connect (connectOptions: {
server?: string; singleplayer?: any; username: string; password?: any; proxy?: any; botVersion?: any; serverOverrides?; serverOverridesFlat?; peerId?: string
}) {
if (miscUiState.gameLoaded) return
miscUiState.hasErrors = false
lastConnectOptions.value = connectOptions
document.getElementById('play-screen').style = 'display: none;'
removePanorama()
Expand Down Expand Up @@ -378,6 +379,7 @@ async function connect (connectOptions: {
console.error(err)
errorAbortController.abort()
if (isCypress()) throw err
miscUiState.hasErrors = true
if (miscUiState.gameLoaded) return

setLoadingScreenStatus(`Error encountered. ${err}`, true)
Expand Down
4 changes: 3 additions & 1 deletion src/loadSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const fsState = proxy({
isReadonly: false,
syncFs: false,
inMemorySave: false,
saveLoaded: false
saveLoaded: false,
openReadOperations: 0,
openWriteOperations: 0,
})

const PROPOSE_BACKUP = true
Expand Down
35 changes: 35 additions & 0 deletions src/react/IndicatorEffects.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.effectsScreen-container {
position: fixed;
top: 6%;
left: 0px;
z-index: -1;
pointer-events: none;
}

.indicators-container {
display: flex;
font-size: 0.7em;
}

.effects-container {
display: flex;
flex-direction: column;
}

.effect-box {
display: flex;
align-items: center;
}

.effect-box__image {
width: 23px;
margin-right: 3px;
}

.effect-box__time {
font-size: 0.65rem;
}

.effect-box__level {
font-size: 0.45rem;
}
35 changes: 35 additions & 0 deletions src/react/IndicatorEffects.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'iconify-icon'

import type { Meta, StoryObj } from '@storybook/react'

import IndicatorEffects, { defaultIndicatorsState } from './IndicatorEffects'
import { images } from './effectsImages'

const meta: Meta<typeof IndicatorEffects> = {
component: IndicatorEffects
}

export default meta
type Story = StoryObj<typeof IndicatorEffects>;

export const Primary: Story = {
args: {
indicators: defaultIndicatorsState,
effects: [
{
image: images.glowing,
time: 200,
level: 255,
removeEffect (image: string) {},
reduceTime (image: string) {}
},
{
image: images.absorption,
time: 30,
level: 99,
removeEffect (image: string) {},
reduceTime (image: string) {}
}
],
}
}
111 changes: 111 additions & 0 deletions src/react/IndicatorEffects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useMemo, useEffect, useRef } from 'react'
import PixelartIcon from './PixelartIcon'
import './IndicatorEffects.css'



function formatTime (seconds: number): string {
if (seconds < 0) return ''
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
const formattedMinutes = String(minutes).padStart(2, '0')
const formattedSeconds = String(remainingSeconds).padStart(2, '0')
return `${formattedMinutes}:${formattedSeconds}`
}

export type EffectType = {
image: string,
time: number,
level: number,
removeEffect: (image: string) => void,
reduceTime: (image: string) => void
}

const EffectBox = ({ image, time, level }: Pick<EffectType, 'image' | 'time' | 'level'>) => {

const formattedTime = useMemo(() => formatTime(time), [time])

return <div className='effect-box'>
<img className='effect-box__image' src={image} alt='' />
<div>
{formattedTime ? (
// if time is negative then effect is shown without time.
// Component should be removed manually with time = 0
<div className='effect-box__time'>{formattedTime}</div>
) : null }
{level > 0 && level < 256 ? (
<div className='effect-box__level'>{level + 1}</div>
) : null }
</div>
</div>
}

export const defaultIndicatorsState = {
chunksLoading: false,
readingFiles: false,
readonlyFiles: false,
writingFiles: false, // saving
appHasErrors: false,
}

const indicatorIcons: Record<keyof typeof defaultIndicatorsState, string> = {
chunksLoading: 'add-grid',
readingFiles: 'arrow-bar-down',
writingFiles: 'arrow-bar-up',
appHasErrors: 'alert',
readonlyFiles: 'file-off',
}

export default ({ indicators, effects }: {indicators: typeof defaultIndicatorsState, effects: readonly EffectType[]}) => {
const effectsRef = useRef(effects)
useEffect(() => {
effectsRef.current = effects
}, [effects])

useEffect(() => {
// todo use more precise timer for each effect
const interval = setInterval(() => {
for (const [index, effect] of effectsRef.current.entries()) {
if (effect.time === 0) {
// effect.removeEffect(effect.image)
return
}
effect.reduceTime(effect.image)
}
}, 1000)

return () => {
clearInterval(interval)
}
}, [])

const indicatorsMapped = Object.entries(defaultIndicatorsState).map(([key, state]) => ({
icon: indicatorIcons[key],
// preserve order
state: indicators[key],
}))
return <div className='effectsScreen-container'>
<div className='indicators-container'>
{
indicatorsMapped.map((indicator) => <div key={indicator.icon} style={{
opacity: indicator.state ? 1 : 0,
transition: 'opacity 0.1s',
}}>
<PixelartIcon iconName={indicator.icon} />
</div>)
}
</div>
<div className='effects-container'>
{
effects.map(
(effect) => <EffectBox
key={`effectBox-${effect.image}`}
image={effect.image}
time={effect.time}
level={effect.level}
/>
)
}
</div>
</div>
}
118 changes: 118 additions & 0 deletions src/react/IndicatorEffectsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { proxy, useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react'
import { inGameError } from '../utils'
import { fsState } from '../loadSave'
import { miscUiState } from '../globalState'
import IndicatorEffects, { EffectType, defaultIndicatorsState } from './IndicatorEffects'
import { images } from './effectsImages'

export const state = proxy({
indicators: {
chunksLoading: false
},
effects: [] as EffectType[]
})

export const addEffect = (newEffect: Omit<EffectType, 'reduceTime' | 'removeEffect'>) => {
const effectIndex = getEffectIndex(newEffect as EffectType)
if (typeof effectIndex === 'number') {
state.effects[effectIndex].time = newEffect.time
state.effects[effectIndex].level = newEffect.level
} else {
const effect = { ...newEffect, reduceTime, removeEffect }
state.effects.push(effect)
}
}

const removeEffect = (image: string) => {
for (const [index, effect] of (state.effects).entries()) {
if (effect.image === image) {
state.effects.splice(index, 1)
}
}
}

const reduceTime = (image: string) => {
for (const [index, effect] of (state.effects).entries()) {
if (effect.image === image) {
effect.time -= 1
}
}
}

const getEffectIndex = (newEffect: EffectType) => {
for (const [index, effect] of (state.effects).entries()) {
if (effect.image === newEffect.image) {
return index
}
}
return null
}

export default () => {
const stateIndicators = useSnapshot(state.indicators)
const { hasErrors } = useSnapshot(miscUiState)
const { isReadonly, openReadOperations, openWriteOperations } = useSnapshot(fsState)
const allIndicators: typeof defaultIndicatorsState = {
readonlyFiles: isReadonly,
writingFiles: openWriteOperations > 0,
readingFiles: openReadOperations > 0,
appHasErrors: hasErrors,
...stateIndicators,
}

useEffect(() => {
let alreadyWaiting = false
const listener = () => {
if (alreadyWaiting) return
state.indicators.chunksLoading = true
alreadyWaiting = true
void viewer.waitForChunksToRender().then(() => {
state.indicators.chunksLoading = false
alreadyWaiting = false
})
}
viewer.world.renderUpdateEmitter.on('dirty', listener)

return () => {
viewer.world.renderUpdateEmitter.off('dirty', listener)
}
}, [])

const effects = useSnapshot(state.effects)

useMemo(() => {
const effectsImages = Object.fromEntries(loadedData.effectsArray.map((effect) => {
const nameKebab = effect.name.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).slice(1)
return [effect.id, images[nameKebab]]
}))
bot.on('entityEffect', (entity, effect) => {
if (entity.id !== bot.entity.id) return
const image = effectsImages[effect.id] ?? null
if (!image) {
inGameError(`received unknown effect id ${effect.id}}`)
return
}
const newEffect = {
image,
time: effect.duration / 20, // duration received in ticks
level: effect.amplifier,
}
addEffect(newEffect)
})
bot.on('entityEffectEnd', (entity, effect) => {
if (entity.id !== bot.entity.id) return
const image = effectsImages[effect.id] ?? null
if (!image) {
inGameError(`received unknown effect id ${effect.id}}}`)
return
}
removeEffect(image)
})
}, [])

return <IndicatorEffects
indicators={allIndicators}
effects={effects}
/>
}
Loading

0 comments on commit 07491fd

Please sign in to comment.