Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
Vitaly Turovsky
253e094c74 add zardoy/mwc-proxy repo ref 2025-10-11 02:25:14 +03:00
Vitaly Turovsky
fef94f03fb feat: add support for alt+arrows navigation to navigate between commands only 2025-10-11 02:25:06 +03:00
Vitaly Turovsky
e9f91f8ecd feat: enable music by default, add slider for controlling its volume 2025-10-11 02:24:51 +03:00
Colbster937
634df8d03d
Add WebMC & WS changes (#431)
Co-authored-by: Colbster937 <96893162+colbychittenden@users.noreply.github.com>
2025-10-11 01:52:06 +03:00
Vitaly Turovsky
a88c8b5470 possible fix for rare edgecase where skins from server were not applied. Cause: renderer due to rare circumnstances could be loaded AFTER gameLoaded which is fired only when starting rendering 3d world. classic no existing data handling issue
why not mineflayerBotCreated? because getThreeJsRendererMethods not available at that time so would make things only much complex
2025-09-30 09:38:37 +03:00
Vitaly Turovsky
f51254d97a fix: dont stop local replay server with keep alive connection error 2025-09-30 07:20:30 +03:00
Vitaly Turovsky
05cd560d6b add shadow and directional light for player in inventory (model viewer) 2025-09-29 02:01:04 +03:00
Vitaly Turovsky
b239636356 feat: add debugServerPacketNames and debugClientPacketNames for quick access of names with intellisense of packets for current protocol. Should be used with window.inspectPacket in console 2025-09-28 22:04:17 +03:00
Vitaly Turovsky
4f421ae45f respect loadPlayerSkins option for inventory skin 2025-09-28 21:59:00 +03:00
Vitaly
3b94889bed
feat: make arrows colorful and metadata (#430)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-09-20 02:57:59 +03:00
Vitaly
636a7fdb54
feat: improve fog a little (#427) 2025-09-19 05:42:22 +03:00
Vitaly Turovsky
c930365e32 fix sometimes inventory player should not be rendered 2025-09-18 07:49:44 +03:00
Vitaly Turovsky
852dd737ae fix: fix some UI like error screen was not visible fully (buttons were clipped behind the screen) on larger scale on large screens 2025-09-11 22:24:04 +03:00
23 changed files with 277 additions and 46 deletions

View file

@ -78,6 +78,8 @@ There is a builtin proxy, but you can also host your one! Just clone the repo, r
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
```mermaid

View file

@ -10,6 +10,10 @@
{
"ip": "wss://play.mcraft.fun"
},
{
"ip": "wss://play.webmc.fun",
"name": "WebMC"
},
{
"ip": "wss://ws.fuchsmc.net"
},

View file

@ -1,4 +1,5 @@
import * as THREE from 'three'
import { DebugGui } from '../lib/DebugGui'
export const DEFAULT_TEMPERATURE = 0.75
@ -17,11 +18,33 @@ export class SkyboxRenderer {
private waterBreathing = false
private fogBrightness = 0
private prevFogBrightness = 0
private readonly fogOrangeness = 0 // Debug property to control sky color orangeness
private readonly distanceFactor = 2.7
private readonly brightnessAtPosition = 1
debugGui: DebugGui
constructor (private readonly scene: THREE.Scene, public defaultSkybox: boolean, public initialImage: string | null) {
this.debugGui = new DebugGui('skybox_renderer', this, [
'temperature',
'worldTime',
'inWater',
'waterBreathing',
'fogOrangeness',
'brightnessAtPosition',
'distanceFactor'
], {
brightnessAtPosition: { min: 0, max: 1, step: 0.01 },
temperature: { min: 0, max: 1, step: 0.01 },
worldTime: { min: 0, max: 24_000, step: 1 },
fogOrangeness: { min: -1, max: 1, step: 0.01 },
distanceFactor: { min: 0, max: 5, step: 0.01 },
})
if (!initialImage) {
this.createGradientSky()
}
// this.debugGui.activate()
}
async init () {
@ -95,6 +118,7 @@ export class SkyboxRenderer {
// Update world time
updateTime (timeOfDay: number, partialTicks = 0) {
if (this.debugGui.visible) return
this.worldTime = timeOfDay
this.partialTicks = partialTicks
this.updateSkyColors()
@ -108,12 +132,14 @@ export class SkyboxRenderer {
// Update temperature (for biome support)
updateTemperature (temperature: number) {
if (this.debugGui.visible) return
this.temperature = temperature
this.updateSkyColors()
}
// Update water state
updateWaterState (inWater: boolean, waterBreathing: boolean) {
if (this.debugGui.visible) return
this.inWater = inWater
this.waterBreathing = waterBreathing
this.updateSkyColors()
@ -121,6 +147,7 @@ export class SkyboxRenderer {
// Update default skybox setting
updateDefaultSkybox (defaultSkybox: boolean) {
if (this.debugGui.visible) return
this.defaultSkybox = defaultSkybox
this.updateSkyColors()
}
@ -229,8 +256,15 @@ export class SkyboxRenderer {
if (temperature < -1) temperature = -1
if (temperature > 1) temperature = 1
const hue = 0.622_222_2 - temperature * 0.05
const saturation = 0.5 + temperature * 0.1
// Apply debug fog orangeness to hue - positive values make it more orange, negative make it less orange
const baseHue = 0.622_222_2 - temperature * 0.05
// Orange is around hue 0.08-0.15, so we need to shift from blue-purple (0.62) toward orange
// Use a more dramatic shift and also increase saturation for more noticeable effect
const orangeHue = 0.12 // Orange hue value
const hue = this.fogOrangeness > 0
? baseHue + (orangeHue - baseHue) * this.fogOrangeness * 0.8 // Blend toward orange
: baseHue + this.fogOrangeness * 0.1 // Subtle shift for negative values
const saturation = 0.5 + temperature * 0.1 + Math.abs(this.fogOrangeness) * 0.3 // Increase saturation with orangeness
const brightness = 1
return this.hsbToRgb(hue, saturation, brightness)
@ -305,8 +339,7 @@ export class SkyboxRenderer {
// Update fog brightness with smooth transition
this.prevFogBrightness = this.fogBrightness
const renderDistance = this.viewDistance / 32
const brightnessAtPosition = 1 // Could be affected by light level in future
const targetBrightness = brightnessAtPosition * (1 - renderDistance) + renderDistance
const targetBrightness = this.brightnessAtPosition * (1 - renderDistance) + renderDistance
this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1
// Handle water fog
@ -340,7 +373,7 @@ export class SkyboxRenderer {
const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness
this.scene.background = new THREE.Color(red, green, blue)
this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * 2)
this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * this.distanceFactor)
;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z))
;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(

View file

@ -16,7 +16,7 @@ export const WAYPOINT_CONFIG = {
CANVAS_SCALE: 2,
ARROW: {
enabledDefault: false,
pixelSize: 30,
pixelSize: 50,
paddingPx: 50,
},
}
@ -50,6 +50,7 @@ export function createWaypointSprite (options: {
depthTest?: boolean,
// Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this)
labelYOffset?: number,
metadata?: any,
}): WaypointSprite {
const color = options.color ?? 0xFF_00_00
const depthTest = options.depthTest ?? false
@ -131,16 +132,22 @@ export function createWaypointSprite (options: {
canvas.height = size
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, size, size)
// Draw arrow shape
ctx.beginPath()
ctx.moveTo(size * 0.2, size * 0.5)
ctx.lineTo(size * 0.8, size * 0.5)
ctx.lineTo(size * 0.5, size * 0.2)
ctx.moveTo(size * 0.15, size * 0.5)
ctx.lineTo(size * 0.85, size * 0.5)
ctx.lineTo(size * 0.5, size * 0.15)
ctx.closePath()
ctx.lineWidth = 4
// Use waypoint color for arrow
const colorHex = `#${color.toString(16).padStart(6, '0')}`
ctx.lineWidth = 6
ctx.strokeStyle = 'black'
ctx.stroke()
ctx.fillStyle = 'white'
ctx.fillStyle = colorHex
ctx.fill()
const texture = new THREE.CanvasTexture(canvas)
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false })
arrowSprite = new THREE.Sprite(material)
@ -169,6 +176,9 @@ export function createWaypointSprite (options: {
ensureArrow()
if (!arrowSprite) return true
// Check if onlyLeftRight is enabled in metadata
const onlyLeftRight = options.metadata?.onlyLeftRight === true
// Build camera basis using camera.up to respect custom orientations
const forward = new THREE.Vector3()
camera.getWorldDirection(forward) // camera look direction
@ -213,6 +223,20 @@ export function createWaypointSprite (options: {
}
}
// Apply onlyLeftRight logic - restrict arrows to left/right edges only
if (onlyLeftRight) {
// Force the arrow to appear only on left or right edges
if (Math.abs(rx) > Math.abs(ry)) {
// Horizontal direction is dominant, keep it
ry = 0
} else {
// Vertical direction is dominant, but we want only left/right
// So choose left or right based on the sign of rx
rx = rx >= 0 ? 1 : -1
ry = 0
}
}
// Place on the rectangle border [-1,1]x[-1,1]
const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1
let ndcX = rx / s

View file

@ -17,6 +17,7 @@ interface WaypointOptions {
color?: number
label?: string
minDistance?: number
metadata?: any
}
export class WaypointsRenderer {
@ -71,13 +72,14 @@ export class WaypointsRenderer {
this.removeWaypoint(id)
const color = options.color ?? 0xFF_00_00
const { label } = options
const { label, metadata } = options
const minDistance = options.minDistance ?? 0
const sprite = createWaypointSprite({
position: new THREE.Vector3(x, y, z),
color,
label: (label || id),
metadata,
})
sprite.enableOffscreenArrow(true)
sprite.setArrowParent(this.waypointScene)

View file

@ -35,7 +35,7 @@ export type AppConfig = {
// defaultVersion?: string
peerJsServer?: string
peerJsServerFallback?: string
promoteServers?: Array<{ ip, description, version? }>
promoteServers?: Array<{ ip, description, name?, version?, }>
mapsProvider?: string
appParams?: Record<string, any> // query string params

View file

@ -7,7 +7,12 @@ let audioContext: AudioContext
const sounds: Record<string, any> = {}
// Track currently playing sounds and their gain nodes
const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = []
const activeSounds: Array<{
source: AudioBufferSourceNode;
gainNode: GainNode;
volumeMultiplier: number;
isMusic: boolean;
}> = []
window.activeSounds = activeSounds
// load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded
@ -43,7 +48,7 @@ export async function loadSound (path: string, contents = path) {
}
}
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false) => {
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => {
const soundBuffer = sounds[url]
if (!soundBuffer) {
const start = Date.now()
@ -51,11 +56,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = option
if (cancelled || Date.now() - start > loadTimeout) return
}
return playSound(url, soundVolume, loop)
return playSound(url, soundVolume, loop, isMusic)
}
export async function playSound (url, soundVolume = 1, loop = false) {
const volume = soundVolume * (options.volume / 100)
export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) {
const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1)
if (!volume) return
@ -82,7 +87,7 @@ export async function playSound (url, soundVolume = 1, loop = false) {
source.start(0)
// Add to active sounds
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume })
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic })
const callbacks = [] as Array<() => void>
source.onended = () => {
@ -110,6 +115,7 @@ export async function playSound (url, soundVolume = 1, loop = false) {
console.warn('Failed to stop sound:', err)
}
},
gainNode,
}
}
@ -137,11 +143,11 @@ export function stopSound (url: string) {
}
}
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) {
const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier } of activeSounds) {
for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) {
try {
gainNode.gain.value = normalizedVolume * volumeMultiplier
gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1)
} catch (err) {
console.warn('Failed to change sound volume:', err)
}
@ -149,5 +155,9 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
}
subscribeKey(options, 'volume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume)
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
})
subscribeKey(options, 'musicVolume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume)
})

View file

@ -82,15 +82,30 @@ const registerWaypointChannels = () => {
{
name: 'color',
type: 'i32'
},
{
name: 'metadataJson',
type: ['pstring', { countType: 'i16' }]
}
]
]
registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => {
// Parse metadata if provided
let metadata: any = {}
if (data.metadataJson && data.metadataJson.trim() !== '') {
try {
metadata = JSON.parse(data.metadataJson)
} catch (error) {
console.warn('Failed to parse waypoint metadataJson:', error)
}
}
getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, {
minDistance: data.minDistance,
label: data.label || undefined,
color: data.color || undefined
color: data.color || undefined,
metadata
})
})

View file

@ -16,7 +16,8 @@ export const defaultOptions = {
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
enableMusic: true,
musicVolume: 50,
// fov: 70,
fov: 75,
defaultPerspective: 'first_person' as 'first_person' | 'third_person_back' | 'third_person_front',

View file

@ -5,6 +5,17 @@ import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree'
import { enable, disable, enabled } from 'debug'
import { Vec3 } from 'vec3'
customEvents.on('mineflayerBotCreated', () => {
window.debugServerPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toClient.types).map(name => {
name = name.replace('packet_', '')
return [name, name]
}))
window.debugClientPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toServer.types).map(name => {
name = name.replace('packet_', '')
return [name, name]
}))
})
window.Vec3 = Vec3
window.cursorBlockRel = (x = 0, y = 0, z = 0) => {
const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z)

View file

@ -246,22 +246,29 @@ customEvents.on('gameLoaded', () => {
}
}
// even if not found, still record to cache
void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl)
void getThreeJsRendererMethods()!.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl)
} catch (err) {
console.error('Error decoding player texture:', err)
reportError(new Error('Error applying skin texture:', { cause: err }))
}
}
bot.on('playerJoined', updateSkin)
bot.on('playerUpdated', updateSkin)
for (const entity of Object.values(bot.players)) {
updateSkin(entity)
}
bot.on('teamUpdated', (team: Team) => {
const teamUpdated = (team: Team) => {
for (const entity of Object.values(bot.entities)) {
if (entity.type === 'player' && entity.username && team.members.includes(entity.username) || entity.uuid && team.members.includes(entity.uuid)) {
bot.emit('entityUpdate', entity)
}
}
})
}
bot.on('teamUpdated', teamUpdated)
for (const team of Object.values(bot.teams)) {
teamUpdated(team)
}
const updateEntityNameTags = (team: Team) => {
for (const entity of Object.values(bot.entities)) {

View file

@ -470,6 +470,7 @@ const openWindow = (type: string | undefined, title: string | any = undefined) =
const isRightClick = type === 'rightclick'
const isLeftClick = type === 'leftclick'
if (isLeftClick || isRightClick) {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item)
}
} else {
@ -501,6 +502,7 @@ const openWindow = (type: string | undefined, title: string | any = undefined) =
if (freeSlot === null) return
void bot.creative.setInventorySlot(freeSlot, item)
} else {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0])
}
}

View file

@ -15,9 +15,12 @@ class CustomDuplex extends Duplex {
}
export const getWebsocketStream = async (host: string) => {
const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss'
const baseProtocol = host.startsWith('ws://') ? 'ws' : 'wss'
const hostClean = host.replace('ws://', '').replace('wss://', '')
const ws = new WebSocket(`${baseProtocol}://${hostClean}`)
const hostURL = new URL(`${baseProtocol}://${hostClean}`)
const hostParams = hostURL.searchParams
hostParams.append('client_mcraft', '')
const ws = new WebSocket(`${baseProtocol}://${hostURL.host}${hostURL.pathname}?${hostParams.toString()}`)
const clientDuplex = new CustomDuplex(undefined, data => {
ws.send(data)
})

View file

@ -480,6 +480,24 @@ export const guiOptionsScheme: {
],
sound: [
{ volume: {} },
{
custom () {
return <OptionSlider
valueOverride={options.enableMusic ? undefined : 0}
onChange={(value) => {
options.musicVolume = value
}}
item={{
type: 'slider',
id: 'musicVolume',
text: 'Music Volume',
min: 0,
max: 100,
unit: '%',
}}
/>
},
},
{
custom () {
return <Button label='Sound Muffler' onClick={() => showModal({ reactType: 'sound-muffler' })} inScreen />

View file

@ -59,6 +59,7 @@ export const startLocalReplayServer = (contents: string) => {
const server = createServer({
Server: LocalServer as any,
version: header.minecraftVersion,
keepAlive: false,
'online-mode': false
})

View file

@ -117,7 +117,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
}
const displayConnectButton = qsParamIp
const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg']
const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg', 'wss://play.webmc.fun']
// pick random example
const example = serverExamples[Math.floor(Math.random() * serverExamples.length)]

View file

@ -125,7 +125,9 @@ export default ({
const chatInput = useRef<HTMLInputElement>(null!)
const chatMessages = useRef<HTMLDivElement>(null)
const chatHistoryPos = useRef(sendHistoryRef.current.length)
const commandHistoryPos = useRef(0)
const inputCurrentlyEnteredValue = useRef('')
const commandHistoryRef = useRef(sendHistoryRef.current.filter((msg: string) => msg.startsWith('/')))
const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened })
const [rightNowAtBottom, setRightNowAtBottom] = useState(false)
@ -142,6 +144,9 @@ export default ({
sendHistoryRef.current = newHistory
window.sessionStorage.chatHistory = JSON.stringify(newHistory)
chatHistoryPos.current = newHistory.length
// Update command history (only messages starting with /)
commandHistoryRef.current = newHistory.filter((msg: string) => msg.startsWith('/'))
commandHistoryPos.current = commandHistoryRef.current.length
}
const acceptComplete = (item: string) => {
@ -180,6 +185,21 @@ export default ({
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
const handleCommandArrowUp = () => {
if (commandHistoryPos.current === 0 || commandHistoryRef.current.length === 0) return
if (commandHistoryPos.current === commandHistoryRef.current.length) { // started navigating command history
inputCurrentlyEnteredValue.current = chatInput.current.value
}
commandHistoryPos.current--
updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || '')
}
const handleCommandArrowDown = () => {
if (commandHistoryPos.current === commandHistoryRef.current.length) return
commandHistoryPos.current++
updateInputValue(commandHistoryRef.current[commandHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
const auxInputFocus = (direction: 'up' | 'down') => {
chatInput.current.focus()
if (direction === 'up') {
@ -203,6 +223,7 @@ export default ({
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
chatHistoryPos.current = sendHistoryRef.current.length
commandHistoryPos.current = commandHistoryRef.current.length
if (!usingTouch) {
chatInput.current.focus()
}
@ -524,9 +545,19 @@ export default ({
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
handleArrowUp()
if (e.altKey) {
handleCommandArrowUp()
e.preventDefault()
} else {
handleArrowUp()
}
} else if (e.code === 'ArrowDown') {
handleArrowDown()
if (e.altKey) {
handleCommandArrowDown()
e.preventDefault()
} else {
handleArrowDown()
}
}
if (e.code === 'Tab') {
if (completionItemsSource.length) {

View file

@ -161,7 +161,15 @@ export const OptionButton = ({ item, onClick, valueText, cacheKey }: {
/>
}
export const OptionSlider = ({ item }: { item: Extract<OptionMeta, { type: 'slider' }> }) => {
export const OptionSlider = ({
item,
onChange,
valueOverride
}: {
item: Extract<OptionMeta, { type: 'slider' }>
onChange?: (value: number) => void
valueOverride?: number
}) => {
const { disabledBecauseOfSetting } = useCommonComponentsProps(item)
const optionValue = useSnapshot(options)[item.id!]
@ -174,7 +182,7 @@ export const OptionSlider = ({ item }: { item: Extract<OptionMeta, { type: 'slid
return (
<Slider
label={item.text!}
value={options[item.id!]}
value={valueOverride ?? options[item.id!]}
data-setting={item.id}
disabledReason={isLocked(item) ? 'qs' : disabledBecauseOfSetting ? `Disabled because ${item.disableIf![0]} is ${item.disableIf![1]}` : item.disabledReason}
min={item.min}
@ -184,6 +192,7 @@ export const OptionSlider = ({ item }: { item: Extract<OptionMeta, { type: 'slid
updateOnDragEnd={item.delayApply}
updateValue={(value) => {
options[item.id!] = value
onChange?.(value)
}}
/>
)

View file

@ -6,6 +6,7 @@ import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject'
import { currentScaling } from '../scaleInterface'
import { activeModalStack } from '../globalState'
THREE.ColorManagement.enabled = false
@ -29,6 +30,7 @@ export const modelViewerState = proxy({
modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } }
resetRotationOnReleae?: boolean
continiousRender?: boolean
alwaysRender?: boolean
}
})
globalThis.modelViewerState = modelViewerState
@ -75,6 +77,15 @@ globalThis.getModelViewerValues = () => {
}
}
subscribe(activeModalStack, () => {
if (!modelViewerState.model || !modelViewerState.model?.alwaysRender) {
return
}
if (activeModalStack.length === 0) {
modelViewerState.model = undefined
}
})
export default () => {
const { model } = useSnapshot(modelViewerState)
const containerRef = useRef<HTMLDivElement>(null)
@ -108,11 +119,15 @@ export default () => {
modelLoaders.current.set(modelUrl, loader)
const onLoad = (object: THREE.Object3D) => {
// Apply customization if available
// Apply customization if available and enable shadows
const customization = model?.modelCustomization?.[modelUrl]
if (customization) {
object.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
// Enable shadow casting and receiving for all meshes
child.castShadow = true
child.receiveShadow = true
if (child.material && customization) {
const material = child.material as THREE.MeshStandardMaterial
if (customization.color) {
material.color.setHex(parseInt(customization.color.replace('#', ''), 16))
@ -128,8 +143,8 @@ export default () => {
material.roughness = customization.roughness
}
}
})
}
}
})
// Center and scale model
const box = new THREE.Box3().setFromObject(object)
@ -248,6 +263,12 @@ export default () => {
}
renderer.setPixelRatio(scale)
renderer.setSize(model.positioning.width, model.positioning.height)
// Enable shadow rendering for depth and realism
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap // Soft shadows for better quality
renderer.shadowMap.autoUpdate = true
containerRef.current.appendChild(renderer.domElement)
// Setup controls
@ -259,10 +280,30 @@ export default () => {
controls.enableDamping = true
controls.dampingFactor = 0.05
// Add ambient light
const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 1)
// Add ambient light for overall illumination
const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 0.4) // Reduced intensity to allow shadows
scene.add(ambientLight)
// Add directional light for shadows and depth (similar to Minecraft inventory lighting)
const directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.6)
directionalLight.position.set(2, 2, 2) // Position light from top-right-front
directionalLight.target.position.set(0, 0, 0) // Point towards center of scene
// Configure shadow properties for optimal quality
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 2048 // High resolution shadow map
directionalLight.shadow.mapSize.height = 2048
directionalLight.shadow.camera.near = 0.1
directionalLight.shadow.camera.far = 10
directionalLight.shadow.camera.left = -3
directionalLight.shadow.camera.right = 3
directionalLight.shadow.camera.top = 3
directionalLight.shadow.camera.bottom = -3
directionalLight.shadow.bias = -0.0001 // Reduce shadow acne
scene.add(directionalLight)
scene.add(directionalLight.target)
// Cursor following function
const updatePlayerLookAt = () => {
if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return
@ -331,6 +372,14 @@ export default () => {
scale: 1 // Start with base scale, will adjust below
})
// Enable shadows for player object
wrapper.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true
child.receiveShadow = true
}
})
// Calculate proper scale and positioning for camera view
const box = new THREE.Box3().setFromObject(wrapper)
const size = box.getSize(new THREE.Vector3())

View file

@ -119,6 +119,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
...serversListProvided,
...(customServersList ? [] : (miscUiState.appConfig?.promoteServers ?? [])).map((server): StoreServerItem => ({
ip: server.ip,
name: server.name,
versionOverride: server.version,
description: server.description,
isRecommended: true
@ -167,6 +168,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
console.log('pingResult.fullInfo.description', pingResult.fullInfo.description)
data = {
formattedText: pingResult.fullInfo.description,
icon: pingResult.fullInfo.favicon,
textNameRight: `ws ${pingResult.latency}ms`,
textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`,
offline: false

View file

@ -26,6 +26,10 @@
display: flex;
justify-content: center;
z-index: 12;
/* Account for GUI scaling */
width: calc(100dvw / var(--guiScale, 1));
height: calc(100dvh / var(--guiScale, 1));
overflow: hidden;
}
.screen-content {

View file

@ -5,10 +5,10 @@ class MusicSystem {
private currentMusic: string | null = null
async playMusic (url: string, musicVolume = 1) {
if (!options.enableMusic || this.currentMusic) return
if (!options.enableMusic || this.currentMusic || options.musicVolume === 0) return
try {
const { onEnded } = await loadOrPlaySound(url, 0.5 * musicVolume, 5000) ?? {}
const { onEnded } = await loadOrPlaySound(url, musicVolume, 5000, undefined, true) ?? {}
if (!onEnded) return

View file

@ -3,6 +3,7 @@
import { subscribeKey } from 'valtio/utils'
import { isMobile } from 'renderer/viewer/lib/simpleUtils'
import { WorldDataEmitter } from 'renderer/viewer/lib/worldDataEmitter'
import { setSkinsConfig } from 'renderer/viewer/lib/utils/skins'
import { options, watchValue } from './optionsStorage'
import { reloadChunks } from './utils'
import { miscUiState } from './globalState'
@ -97,6 +98,8 @@ export const watchOptionsAfterViewerInit = () => {
appViewer.inWorldRenderingConfig.highlightBlockColor = o.highlightBlockColor
appViewer.inWorldRenderingConfig._experimentalSmoothChunkLoading = o.rendererSharedOptions._experimentalSmoothChunkLoading
appViewer.inWorldRenderingConfig._renderByChunks = o.rendererSharedOptions._renderByChunks
setSkinsConfig({ apiEnabled: o.loadPlayerSkins })
})
appViewer.inWorldRenderingConfig.smoothLighting = options.smoothLighting