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) [![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. 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 ```mermaid

View file

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

View file

@ -1,4 +1,5 @@
import * as THREE from 'three' import * as THREE from 'three'
import { DebugGui } from '../lib/DebugGui'
export const DEFAULT_TEMPERATURE = 0.75 export const DEFAULT_TEMPERATURE = 0.75
@ -17,11 +18,33 @@ export class SkyboxRenderer {
private waterBreathing = false private waterBreathing = false
private fogBrightness = 0 private fogBrightness = 0
private prevFogBrightness = 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) { 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) { if (!initialImage) {
this.createGradientSky() this.createGradientSky()
} }
// this.debugGui.activate()
} }
async init () { async init () {
@ -95,6 +118,7 @@ export class SkyboxRenderer {
// Update world time // Update world time
updateTime (timeOfDay: number, partialTicks = 0) { updateTime (timeOfDay: number, partialTicks = 0) {
if (this.debugGui.visible) return
this.worldTime = timeOfDay this.worldTime = timeOfDay
this.partialTicks = partialTicks this.partialTicks = partialTicks
this.updateSkyColors() this.updateSkyColors()
@ -108,12 +132,14 @@ export class SkyboxRenderer {
// Update temperature (for biome support) // Update temperature (for biome support)
updateTemperature (temperature: number) { updateTemperature (temperature: number) {
if (this.debugGui.visible) return
this.temperature = temperature this.temperature = temperature
this.updateSkyColors() this.updateSkyColors()
} }
// Update water state // Update water state
updateWaterState (inWater: boolean, waterBreathing: boolean) { updateWaterState (inWater: boolean, waterBreathing: boolean) {
if (this.debugGui.visible) return
this.inWater = inWater this.inWater = inWater
this.waterBreathing = waterBreathing this.waterBreathing = waterBreathing
this.updateSkyColors() this.updateSkyColors()
@ -121,6 +147,7 @@ export class SkyboxRenderer {
// Update default skybox setting // Update default skybox setting
updateDefaultSkybox (defaultSkybox: boolean) { updateDefaultSkybox (defaultSkybox: boolean) {
if (this.debugGui.visible) return
this.defaultSkybox = defaultSkybox this.defaultSkybox = defaultSkybox
this.updateSkyColors() this.updateSkyColors()
} }
@ -229,8 +256,15 @@ export class SkyboxRenderer {
if (temperature < -1) temperature = -1 if (temperature < -1) temperature = -1
if (temperature > 1) temperature = 1 if (temperature > 1) temperature = 1
const hue = 0.622_222_2 - temperature * 0.05 // Apply debug fog orangeness to hue - positive values make it more orange, negative make it less orange
const saturation = 0.5 + temperature * 0.1 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 const brightness = 1
return this.hsbToRgb(hue, saturation, brightness) return this.hsbToRgb(hue, saturation, brightness)
@ -305,8 +339,7 @@ export class SkyboxRenderer {
// Update fog brightness with smooth transition // Update fog brightness with smooth transition
this.prevFogBrightness = this.fogBrightness this.prevFogBrightness = this.fogBrightness
const renderDistance = this.viewDistance / 32 const renderDistance = this.viewDistance / 32
const brightnessAtPosition = 1 // Could be affected by light level in future const targetBrightness = this.brightnessAtPosition * (1 - renderDistance) + renderDistance
const targetBrightness = brightnessAtPosition * (1 - renderDistance) + renderDistance
this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1 this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1
// Handle water fog // Handle water fog
@ -340,7 +373,7 @@ export class SkyboxRenderer {
const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness
this.scene.background = new THREE.Color(red, green, blue) 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.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( ;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(

View file

@ -16,7 +16,7 @@ export const WAYPOINT_CONFIG = {
CANVAS_SCALE: 2, CANVAS_SCALE: 2,
ARROW: { ARROW: {
enabledDefault: false, enabledDefault: false,
pixelSize: 30, pixelSize: 50,
paddingPx: 50, paddingPx: 50,
}, },
} }
@ -50,6 +50,7 @@ export function createWaypointSprite (options: {
depthTest?: boolean, depthTest?: boolean,
// Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this) // Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this)
labelYOffset?: number, labelYOffset?: number,
metadata?: any,
}): WaypointSprite { }): WaypointSprite {
const color = options.color ?? 0xFF_00_00 const color = options.color ?? 0xFF_00_00
const depthTest = options.depthTest ?? false const depthTest = options.depthTest ?? false
@ -131,16 +132,22 @@ export function createWaypointSprite (options: {
canvas.height = size canvas.height = size
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, size, size) ctx.clearRect(0, 0, size, size)
// Draw arrow shape
ctx.beginPath() ctx.beginPath()
ctx.moveTo(size * 0.2, size * 0.5) ctx.moveTo(size * 0.15, size * 0.5)
ctx.lineTo(size * 0.8, size * 0.5) ctx.lineTo(size * 0.85, size * 0.5)
ctx.lineTo(size * 0.5, size * 0.2) ctx.lineTo(size * 0.5, size * 0.15)
ctx.closePath() 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.strokeStyle = 'black'
ctx.stroke() ctx.stroke()
ctx.fillStyle = 'white' ctx.fillStyle = colorHex
ctx.fill() ctx.fill()
const texture = new THREE.CanvasTexture(canvas) const texture = new THREE.CanvasTexture(canvas)
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }) const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false })
arrowSprite = new THREE.Sprite(material) arrowSprite = new THREE.Sprite(material)
@ -169,6 +176,9 @@ export function createWaypointSprite (options: {
ensureArrow() ensureArrow()
if (!arrowSprite) return true 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 // Build camera basis using camera.up to respect custom orientations
const forward = new THREE.Vector3() const forward = new THREE.Vector3()
camera.getWorldDirection(forward) // camera look direction 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] // Place on the rectangle border [-1,1]x[-1,1]
const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1 const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1
let ndcX = rx / s let ndcX = rx / s

View file

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

View file

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

View file

@ -7,7 +7,12 @@ let audioContext: AudioContext
const sounds: Record<string, any> = {} const sounds: Record<string, any> = {}
// Track currently playing sounds and their gain nodes // 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 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 // 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] const soundBuffer = sounds[url]
if (!soundBuffer) { if (!soundBuffer) {
const start = Date.now() const start = Date.now()
@ -51,11 +56,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = option
if (cancelled || Date.now() - start > loadTimeout) return 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) { export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) {
const volume = soundVolume * (options.volume / 100) const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1)
if (!volume) return if (!volume) return
@ -82,7 +87,7 @@ export async function playSound (url, soundVolume = 1, loop = false) {
source.start(0) source.start(0)
// Add to active sounds // Add to active sounds
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume }) activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic })
const callbacks = [] as Array<() => void> const callbacks = [] as Array<() => void>
source.onended = () => { source.onended = () => {
@ -110,6 +115,7 @@ export async function playSound (url, soundVolume = 1, loop = false) {
console.warn('Failed to stop sound:', err) 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 const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier } of activeSounds) { for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) {
try { try {
gainNode.gain.value = normalizedVolume * volumeMultiplier gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1)
} catch (err) { } catch (err) {
console.warn('Failed to change sound volume:', err) console.warn('Failed to change sound volume:', err)
} }
@ -149,5 +155,9 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
} }
subscribeKey(options, 'volume', () => { 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', name: 'color',
type: 'i32' type: 'i32'
},
{
name: 'metadataJson',
type: ['pstring', { countType: 'i16' }]
} }
] ]
] ]
registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => { 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, { getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, {
minDistance: data.minDistance, minDistance: data.minDistance,
label: data.label || undefined, label: data.label || undefined,
color: data.color || undefined color: data.color || undefined,
metadata
}) })
}) })

View file

@ -16,7 +16,8 @@ export const defaultOptions = {
chatOpacityOpened: 100, chatOpacityOpened: 100,
messagesLimit: 200, messagesLimit: 200,
volume: 50, volume: 50,
enableMusic: false, enableMusic: true,
musicVolume: 50,
// fov: 70, // fov: 70,
fov: 75, fov: 75,
defaultPerspective: 'first_person' as 'first_person' | 'third_person_back' | 'third_person_front', 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 { enable, disable, enabled } from 'debug'
import { Vec3 } from 'vec3' 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.Vec3 = Vec3
window.cursorBlockRel = (x = 0, y = 0, z = 0) => { window.cursorBlockRel = (x = 0, y = 0, z = 0) => {
const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z) 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 // 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) { } catch (err) {
console.error('Error decoding player texture:', err) reportError(new Error('Error applying skin texture:', { cause: err }))
} }
} }
bot.on('playerJoined', updateSkin) bot.on('playerJoined', updateSkin)
bot.on('playerUpdated', 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)) { 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)) { if (entity.type === 'player' && entity.username && team.members.includes(entity.username) || entity.uuid && team.members.includes(entity.uuid)) {
bot.emit('entityUpdate', entity) bot.emit('entityUpdate', entity)
} }
} }
}) }
bot.on('teamUpdated', teamUpdated)
for (const team of Object.values(bot.teams)) {
teamUpdated(team)
}
const updateEntityNameTags = (team: Team) => { const updateEntityNameTags = (team: Team) => {
for (const entity of Object.values(bot.entities)) { 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 isRightClick = type === 'rightclick'
const isLeftClick = type === 'leftclick' const isLeftClick = type === 'leftclick'
if (isLeftClick || isRightClick) { if (isLeftClick || isRightClick) {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item) inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item)
} }
} else { } else {
@ -501,6 +502,7 @@ const openWindow = (type: string | undefined, title: string | any = undefined) =
if (freeSlot === null) return if (freeSlot === null) return
void bot.creative.setInventorySlot(freeSlot, item) void bot.creative.setInventorySlot(freeSlot, item)
} else { } else {
modelViewerState.model = undefined
inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0]) 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) => { 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 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 => { const clientDuplex = new CustomDuplex(undefined, data => {
ws.send(data) ws.send(data)
}) })

View file

@ -480,6 +480,24 @@ export const guiOptionsScheme: {
], ],
sound: [ sound: [
{ volume: {} }, { 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 () { custom () {
return <Button label='Sound Muffler' onClick={() => showModal({ reactType: 'sound-muffler' })} inScreen /> 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({ const server = createServer({
Server: LocalServer as any, Server: LocalServer as any,
version: header.minecraftVersion, version: header.minecraftVersion,
keepAlive: false,
'online-mode': false 'online-mode': false
}) })

View file

@ -117,7 +117,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
} }
const displayConnectButton = qsParamIp 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 // pick random example
const example = serverExamples[Math.floor(Math.random() * serverExamples.length)] const example = serverExamples[Math.floor(Math.random() * serverExamples.length)]

View file

@ -125,7 +125,9 @@ export default ({
const chatInput = useRef<HTMLInputElement>(null!) const chatInput = useRef<HTMLInputElement>(null!)
const chatMessages = useRef<HTMLDivElement>(null) const chatMessages = useRef<HTMLDivElement>(null)
const chatHistoryPos = useRef(sendHistoryRef.current.length) const chatHistoryPos = useRef(sendHistoryRef.current.length)
const commandHistoryPos = useRef(0)
const inputCurrentlyEnteredValue = useRef('') const inputCurrentlyEnteredValue = useRef('')
const commandHistoryRef = useRef(sendHistoryRef.current.filter((msg: string) => msg.startsWith('/')))
const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened }) const { scrollToBottom, isAtBottom, wasAtBottom, currentlyAtBottom } = useScrollBehavior(chatMessages, { messages, opened })
const [rightNowAtBottom, setRightNowAtBottom] = useState(false) const [rightNowAtBottom, setRightNowAtBottom] = useState(false)
@ -142,6 +144,9 @@ export default ({
sendHistoryRef.current = newHistory sendHistoryRef.current = newHistory
window.sessionStorage.chatHistory = JSON.stringify(newHistory) window.sessionStorage.chatHistory = JSON.stringify(newHistory)
chatHistoryPos.current = newHistory.length 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) => { const acceptComplete = (item: string) => {
@ -180,6 +185,21 @@ export default ({
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '') 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') => { const auxInputFocus = (direction: 'up' | 'down') => {
chatInput.current.focus() chatInput.current.focus()
if (direction === 'up') { if (direction === 'up') {
@ -203,6 +223,7 @@ export default ({
updateInputValue(chatInputValueGlobal.value) updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = '' chatInputValueGlobal.value = ''
chatHistoryPos.current = sendHistoryRef.current.length chatHistoryPos.current = sendHistoryRef.current.length
commandHistoryPos.current = commandHistoryRef.current.length
if (!usingTouch) { if (!usingTouch) {
chatInput.current.focus() chatInput.current.focus()
} }
@ -524,9 +545,19 @@ export default ({
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.code === 'ArrowUp') { if (e.code === 'ArrowUp') {
handleArrowUp() if (e.altKey) {
handleCommandArrowUp()
e.preventDefault()
} else {
handleArrowUp()
}
} else if (e.code === 'ArrowDown') { } else if (e.code === 'ArrowDown') {
handleArrowDown() if (e.altKey) {
handleCommandArrowDown()
e.preventDefault()
} else {
handleArrowDown()
}
} }
if (e.code === 'Tab') { if (e.code === 'Tab') {
if (completionItemsSource.length) { 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 { disabledBecauseOfSetting } = useCommonComponentsProps(item)
const optionValue = useSnapshot(options)[item.id!] const optionValue = useSnapshot(options)[item.id!]
@ -174,7 +182,7 @@ export const OptionSlider = ({ item }: { item: Extract<OptionMeta, { type: 'slid
return ( return (
<Slider <Slider
label={item.text!} label={item.text!}
value={options[item.id!]} value={valueOverride ?? options[item.id!]}
data-setting={item.id} data-setting={item.id}
disabledReason={isLocked(item) ? 'qs' : disabledBecauseOfSetting ? `Disabled because ${item.disableIf![0]} is ${item.disableIf![1]}` : item.disabledReason} disabledReason={isLocked(item) ? 'qs' : disabledBecauseOfSetting ? `Disabled because ${item.disableIf![0]} is ${item.disableIf![1]}` : item.disabledReason}
min={item.min} min={item.min}
@ -184,6 +192,7 @@ export const OptionSlider = ({ item }: { item: Extract<OptionMeta, { type: 'slid
updateOnDragEnd={item.delayApply} updateOnDragEnd={item.delayApply}
updateValue={(value) => { updateValue={(value) => {
options[item.id!] = 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 { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject' import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject'
import { currentScaling } from '../scaleInterface' import { currentScaling } from '../scaleInterface'
import { activeModalStack } from '../globalState'
THREE.ColorManagement.enabled = false THREE.ColorManagement.enabled = false
@ -29,6 +30,7 @@ export const modelViewerState = proxy({
modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } } modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } }
resetRotationOnReleae?: boolean resetRotationOnReleae?: boolean
continiousRender?: boolean continiousRender?: boolean
alwaysRender?: boolean
} }
}) })
globalThis.modelViewerState = modelViewerState 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 () => { export default () => {
const { model } = useSnapshot(modelViewerState) const { model } = useSnapshot(modelViewerState)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -108,11 +119,15 @@ export default () => {
modelLoaders.current.set(modelUrl, loader) modelLoaders.current.set(modelUrl, loader)
const onLoad = (object: THREE.Object3D) => { const onLoad = (object: THREE.Object3D) => {
// Apply customization if available // Apply customization if available and enable shadows
const customization = model?.modelCustomization?.[modelUrl] const customization = model?.modelCustomization?.[modelUrl]
if (customization) { object.traverse((child) => {
object.traverse((child) => { if (child instanceof THREE.Mesh) {
if (child instanceof THREE.Mesh && child.material) { // 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 const material = child.material as THREE.MeshStandardMaterial
if (customization.color) { if (customization.color) {
material.color.setHex(parseInt(customization.color.replace('#', ''), 16)) material.color.setHex(parseInt(customization.color.replace('#', ''), 16))
@ -128,8 +143,8 @@ export default () => {
material.roughness = customization.roughness material.roughness = customization.roughness
} }
} }
}) }
} })
// Center and scale model // Center and scale model
const box = new THREE.Box3().setFromObject(object) const box = new THREE.Box3().setFromObject(object)
@ -248,6 +263,12 @@ export default () => {
} }
renderer.setPixelRatio(scale) renderer.setPixelRatio(scale)
renderer.setSize(model.positioning.width, model.positioning.height) 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) containerRef.current.appendChild(renderer.domElement)
// Setup controls // Setup controls
@ -259,10 +280,30 @@ export default () => {
controls.enableDamping = true controls.enableDamping = true
controls.dampingFactor = 0.05 controls.dampingFactor = 0.05
// Add ambient light // Add ambient light for overall illumination
const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 1) const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 0.4) // Reduced intensity to allow shadows
scene.add(ambientLight) 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 // Cursor following function
const updatePlayerLookAt = () => { const updatePlayerLookAt = () => {
if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return
@ -331,6 +372,14 @@ export default () => {
scale: 1 // Start with base scale, will adjust below 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 // Calculate proper scale and positioning for camera view
const box = new THREE.Box3().setFromObject(wrapper) const box = new THREE.Box3().setFromObject(wrapper)
const size = box.getSize(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3())

View file

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

View file

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

View file

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

View file

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