Gardener Shu (devlog)

,

To run complex NPC behaviours I used web workers:

  • Demon AI Decision
  • Demon AI Vision
  • Demon AI Pathfinding.
7 Likes

The last frame of your video is hilarious :smiley:

The content is cool as well :wink:

WebWorkers are a relatively advanced feature that many developers are unfamiliar with. While SharedArrayBuffer already enables zero-copy shared memory between the main thread and worker threads, future evolutions of the WebWorkers API may further reduce or eliminate the need for explicit data transfer. I can’t wait for it!

1 Like

And I love to put a face on a name! Nice to see you @onekit

2 Likes

:christmas_tree: Pre-New Year (final for 2025) progress report on the development of the game Gardener Shu.
#gardenexplorer #gardenershu #gamedev

:globe_showing_europe_africa: Localization: Belarusian and Ukrainian languages ​​added
:gear: Interface: Scrollbar added to the game menu

Upd. v0.11.7 gameplay

6 Likes

lol, I love the attitude: “I don’t want to harvest that crap - I wanna kick it, then shoot it!” :grin:

2 Likes

Gardener Shu — What’s new since 0.11.7 (now 0.19.0)

I didn’t post updates for a while, so here’s a condensed overview of what’s been added and improved.

Engine & tech

  • Babylon.js updated to 8.49.5;

  • Large refactors across the codebase; main-thread and worker memory leak fixes; optimized math (trig, random, constants).

New gameplay & content

  • New player model (toddler).

  • Wolves: full enemy with animations, hurt/death, sounds, wander, mushroom search, collision, 3D health bars, corpse handling; “wolf” cheat to spawn.

  • Helicopter: new enemy (spawn via “helicopter” cheat), ascent, banking movement, blade animation, hurt/death, mission destroy objectives.

  • Demons: triple jump attack, improved blood effects, diamond scaling by size, collision balance.

  • Jump & landing: three stages (soft landing, light damage, hard impact + stun), “fly” animation, higher run jump.

  • Shotgun: pellets in object pool, stronger impact and knockback.

  • Ammo: ammo packs (e.g. 25 rounds) with amount tracking; full vs partial pack models; reload/chamber sync with server.

  • Apple: healing scales with apple size (e.g. +6% / +15%).

  • Credits: dance during credits; Register (EULA); Google and Telegram login.

Camera & controls

  • Smooth third-person zoom with priority (user vs game); smooth camera roll reset after cinematics.

  • Scroll to switch camera: third-person ↔ POV ↔ Inventory; lower zoom sensitivity; mouse sensitivity up to 500% with proper save/restore.

  • Pointer lock and mouse sensitivity persist across level changes; camera sync fix when switching POV/third-person.

Multiplayer & network

  • Reliable handshake with retries and confirmation; level filtering so players only get updates for their level.

  • Inventory sync: pickUp, drop, reload, shoot, flashlight state; server connection monitor with on-screen status.

  • 3D chat popovers above players; 3D health bars in world (billboard/vertical) instead of only 2D UI.

World & UI

  • 3D selection box that follows terrain height; pants color from level data.

  • Fog fix on first load; settings merge so options (e.g. fog) load correctly.

  • Mission/tutorial text formatting; cheat codes “helicopter”, “wolf”, “iddqd”, “rich”; improved cheat storage.

Fixes & polish

  • Movement stuck and landing slide; frame-rate independent movement; idle state (stand/crouch/lay) fixes.

(Network mode is under construction yet).

:rocket: Early access: https://game.1kit.net

4 Likes

What’s new in version 0.21.9:

  • Asset packaging: implemented fflate for resource compression; now loading a single sounds.zip instead of making 200+ server requests for individual .ogg and .mp3 files.

  • Destructible rock: added a rock object (pre-fractured into 10 shards using the Blender Cell Fracture add-on).


  • New building: added a small house with round windows.

  • New particle systems: helicopter is on fire.

  • Wolf AI updates: the wolf is now more autonomous—it eats apples when health is low, hunts for mushrooms, and leads the player to their location.

  • Added a bug: level transitions are currently broken and player respawn is disabled. :cry:
    This surfaced after switching from sequential to parallel asset loading. Since many game components are interdependent, the dependency chain is conflicting. I’m currently refactoring the code and aiming for a beta release in March 2026.

4 Likes

What’s new in version 0.25.6:

  • Fixed bugs with level transitions and respawning after death.
  • Globally optimized the behavior of demons, wolves, and helicopters.
  • Added a WaterMaterial and shader.

I had previously seen examples of similar shaders on the forum, but I always thought they would be too performance-heavy and costly to implement. After taking a closer look, I decided to give it a try and integrated it.

Demo: https://game.1kit.net
Click “Play” under “Single player mode.”
Once the game has loaded, click anywhere on the screen.
Press Enter and type “ocean” to switch to “shader ocean” mode.
Type “ocean” again to switch back to “WaterMaterial” mode.

4 Likes

how can you make the new ocean reflect sky

For now, I’m thinking of dynamically recoloring the water with the same color scheme, so that it darkens and lightens, and matches the sky’s tones. I don’t yet know how to achieve a mirror-like effect, like with WaterMaterial.


I can add more red color to the sunset time.

4 Likes

@withADoveInOneHand Here is an example of chameleon water: :grin:

It’s not a real reflection, but the sky and water can match colors.

2 Likes

I love the vibe.

1 Like

What’s new in version 0.30.2:
WaterMaterial with Havok shape and particles system:

CreateDecal to create holes in the ground:

2 Likes

What’s new in 0.32.2:

  • Recently, a user named @southofsouthrecords posted a thread on the forum about a blood effect, which got me thinking that the Particle System isn’t enough for me anymore;
    Blood is now generated not only using the Particle System but also via small physical shapes that turn into decals upon colliding with the ground.
    To prevent them from slowing down the game, garbage is cleared from the level once per second.
    For the dispersion, I’m using fast trigonometryfastSin and fastCos instead of the native Math.sin and Math.cos .
  • And the second thing I managed to add is detachable helicopter wheels.
    Previously, when a helicopter was destroyed, only the propellers would fly off with a whistling sound, but now the wheels fall off too.

:grin:

5 Likes

Amazing !!! :slight_smile:

1 Like

What’s new in 0.41.8:

Obstacle transparency (dithering or x-ray)
When you move behind an object in isometric or third-person view, it becomes transparent so you don’t lose sight of the player and understand what’s happening to them. This will be much more convenient.

4 Likes

That’s such a great quality of life feature!

1 Like

I’m using a shader that samples the ambient light intensity and adjusts the brightness, contrast, and other texture parameters of the mesh so it blends in with the game world. Since my game has a day/night cycle, I need that consistency. I also have an implementation using mesh.visibility , but I’ve been having trouble with it lately, so I decided to go the shader route instead.

DitherBehavior.ts

import { Environment } from '@game/environment'
import {
  type AbstractMesh,
  Color3,
  Effect,
  Engine,
  type Material,
  type Observer,
  type Scene,
  ShaderMaterial,
  type Texture,
} from 'babylonjs'

const VERTEX_SHADER = `
precision highp float;

attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;

uniform mat4 world;
uniform mat4 worldViewProjection;

varying vec2 vUV;
varying vec3 vNormal;

void main() {
  gl_Position = worldViewProjection * vec4(position, 1.0);
  vUV = uv;
  vNormal = normalize((world * vec4(normal, 0.0)).xyz);
}
`

const FRAGMENT_SHADER = `
precision highp float;

varying vec2 vUV;
varying vec3 vNormal;

uniform sampler2D diffuseSampler;
uniform float alpha;
uniform float hasDiffuseTexture;
uniform vec3 diffuseColor;
uniform float contrast;
uniform float brightness;

vec3 adjustContrast(vec3 color, float contrast) {
  return (color - 0.5) * contrast + 0.5;
}

vec3 adjustBrightness(vec3 color, float brightness) {
  return color + brightness;
}

void main() {
  vec4 color = vec4(diffuseColor, 1.0);
  
  if (hasDiffuseTexture > 0.5) {
    color = texture2D(diffuseSampler, vUV);
  }

  color.rgb = adjustContrast(color.rgb, contrast);
  color.rgb = adjustBrightness(color.rgb, brightness);

  gl_FragColor = vec4(color.rgb, color.a * alpha);
}
`

let registered = false

function registerShader(): void {
  if (registered) return
  Effect.ShadersStore['ditherAlphaVertexShader'] = VERTEX_SHADER
  Effect.ShadersStore['ditherAlphaFragmentShader'] = FRAGMENT_SHADER
  registered = true
}

function createDitherMaterial(scene: Scene, hasTexture: boolean = false): ShaderMaterial {
  registerShader()
  const mat = new ShaderMaterial(
    'ditherAlphaOcclusion',
    scene,
    { vertex: 'ditherAlpha', fragment: 'ditherAlpha' },
    {
      attributes: ['position', 'uv', 'normal'],
      uniforms: ['world', 'worldViewProjection', 'alpha', 'hasDiffuseTexture', 'diffuseColor', 'contrast', 'brightness'],
      samplers: ['diffuseSampler'],
    },
  )
  mat.setFloat('alpha', hasTexture ? 1.0 : 0.99)
  mat.setFloat('hasDiffuseTexture', 0.0)
  mat.setColor3('diffuseColor', new Color3(1, 1, 1))
  mat.setFloat('contrast', 0.7)
  mat.setFloat('brightness', 0.1)
  mat.backFaceCulling = false
  mat.alphaMode = Engine.ALPHA_COMBINE
  if (hasTexture) {
    mat.needAlphaBlending = () => true
  }
  return mat
}

function copyTexturesToShaderMaterial(original: Material, shader: ShaderMaterial): void {
  // StandardMaterial - uses diffuseTexture
  if ('diffuseTexture' in original) {
    const diffuse = (original as { diffuseTexture: Texture | null }).diffuseTexture
    if (diffuse) {
      shader.setTexture('diffuseSampler', diffuse)
      shader.setFloat('hasDiffuseTexture', 1.0)
      return
    }
    // Also copy diffuse color
    if ('diffuseColor' in original) {
      const color = (original as { diffuseColor: { r: number; g: number; b: number } }).diffuseColor
      shader.setColor3('diffuseColor', new Color3(color.r, color.g, color.b))
    }
  }

  // PBRMaterial - uses albedoTexture
  if ('albedoTexture' in original) {
    const albedo = (original as { albedoTexture: Texture | null }).albedoTexture
    if (albedo) {
      shader.setTexture('diffuseSampler', albedo)
      shader.setFloat('hasDiffuseTexture', 1.0)
      return
    }
    // Also copy albedo color
    if ('albedoColor' in original) {
      const color = (original as { albedoColor: { r: number; g: number; b: number } }).albedoColor
      shader.setColor3('diffuseColor', new Color3(color.r, color.g, color.b))
    }
  }

  shader.setFloat('hasDiffuseTexture', 0.0)
  shader.setColor3('diffuseColor', new Color3(1, 1, 1))
}

const TARGET_ALPHA = Number(import.meta.env.VITE_CAMERA_DITHER_ALPHA) || 0.3
const FADE_SPEED = Number(import.meta.env.VITE_CAMERA_DITHER_FADE_SPEED) || 0.1

enum DitherMode {
  Shader = 'shader',
  Visibility = 'visibility',
}

const DITHER_MODE = (import.meta.env.VITE_CAMERA_DITHER_MODE as DitherMode) || DitherMode.Shader

interface MaterialState {
  original: Material | null
  shader: ShaderMaterial
  originalVisibility: number
  originalAlpha?: number
  originalTransparencyMode?: number
}

export default class DitherBehavior {
  readonly name = 'ditherOcclusion'
  attachedNode: AbstractMesh | null = null

  private materialStates = new Map<string, MaterialState>()
  private observer: Observer<Scene> | null = null
  private cleaningUp = false
  private fadingOut = true
  private currentAlpha = 1.0
  private targetAlpha = TARGET_ALPHA

  init(): void {}

  attach(target: AbstractMesh): void {
    this.attachedNode = target
    this.targetAlpha = TARGET_ALPHA

    if (DITHER_MODE === DitherMode.Visibility) {
      this.prepareVisibilityMode(target)
    } else {
      this.createShaderMaterials(target)
    }

    this.fadingOut = true
    this.currentAlpha = 1.0

    this.observer = target.getScene().onBeforeRenderObservable.add(() => {
      this.tick()
    })
  }

  private prepareVisibilityMode(target: AbstractMesh): void {
    const states = new Map<string, MaterialState>()

    const rootOriginal = target.material
    if (rootOriginal) {
      const originalVisibility = target.visibility
      target.visibility = 1.0
      const state = this.forceMaterialTransparency(rootOriginal)
      states.set(target.uniqueId.toString(), {
        original: rootOriginal,
        shader: null as unknown as ShaderMaterial,
        originalVisibility,
        originalAlpha: state.originalAlpha,
        originalTransparencyMode: state.originalTransparencyMode,
      })
    }

    target.getChildMeshes().forEach((child) => {
      const childOriginal = child.material
      if (childOriginal) {
        const originalVisibility = child.visibility
        child.visibility = 1.0
        const state = this.forceMaterialTransparency(childOriginal)
        states.set(child.uniqueId.toString(), {
          original: childOriginal,
          shader: null as unknown as ShaderMaterial,
          originalVisibility,
          originalAlpha: state.originalAlpha,
          originalTransparencyMode: state.originalTransparencyMode,
        })
      }
    })

    this.materialStates = states
  }

  private forceMaterialTransparency(_material: Material): { originalAlpha?: number; originalTransparencyMode?: number } {
    const result = {
      originalAlpha: undefined,
      originalTransparencyMode: undefined,
    }
    return result
  }

  detach(): void {
    if (this.cleaningUp) return
    this.immediateRestore()
  }

  fadeOut(): void {
    if (this.cleaningUp) return
    this.fadingOut = true
    this.targetAlpha = TARGET_ALPHA
  }

  dispose(): void {
    if (this.cleaningUp) return
    this.immediateRestore()
  }

  private immediateRestore(): void {
    if (this.cleaningUp || !this.attachedNode) return
    this.cleaningUp = true

    const mesh = this.attachedNode
    if (this.observer) {
      const scene = mesh?.getScene()
      if (scene) {
        scene.onBeforeRenderObservable.remove(this.observer)
      }
      this.observer = null
    }

    if (mesh && !mesh.isDisposed()) {
      if (DITHER_MODE === DitherMode.Visibility) {
        this.restoreVisibility(mesh)
      } else {
        this.restoreMaterials(mesh)
      }
      mesh.removeBehavior(this)
    }

    this.materialStates.clear()
    this.attachedNode = null
    this.cleaningUp = false
  }

  private tick(): void {
    if (this.cleaningUp || !this.attachedNode) return

    const mesh = this.attachedNode
    if (!mesh || mesh.isDisposed()) {
      this.cleanup()
      return
    }

    const diff = this.targetAlpha - this.currentAlpha
    if (Math.abs(diff) < 0.005) {
      this.currentAlpha = this.targetAlpha
      if (!this.fadingOut && this.currentAlpha >= 0.995) {
        this.cleanup()
      }
      return
    }

    this.currentAlpha += diff * FADE_SPEED

    this.updateLightingUniforms()
    this.applyAlphaToMesh(mesh)
  }

  private updateLightingUniforms(): void {
    const light = Environment.light
    if (!light) return

    const lightColor = light.lightColor
    const colorIntensity = (lightColor.r + lightColor.g + lightColor.b) / 3
    const lightIntensity = light.lightIntensity ?? 1
    const intensity = colorIntensity * lightIntensity

    const baseContrast = 0.7
    const baseBrightness = 0.0

    const contrast = baseContrast * (1 - intensity * 0.05)
    const brightness = baseBrightness + intensity * 0.05

    this.materialStates.forEach((state) => {
      if (state.shader) {
        state.shader.setFloat('contrast', contrast)
        state.shader.setFloat('brightness', brightness)
      }
    })
  }

  private applyAlphaToMesh(mesh: AbstractMesh): void {
    if (DITHER_MODE === DitherMode.Visibility) {
      this.applyVisibilityToMesh(mesh)
    } else {
      this.applyShaderToMesh(mesh)
    }
  }

  private applyShaderToMesh(mesh: AbstractMesh): void {
    const rootState = this.materialStates.get(mesh.uniqueId.toString())
    if (rootState?.shader) {
      rootState.shader.setFloat('alpha', this.currentAlpha)
    }

    mesh.getChildMeshes().forEach((child) => {
      if (!child.isDisposed()) {
        const childState = this.materialStates.get(child.uniqueId.toString())
        if (childState?.shader) {
          childState.shader.setFloat('alpha', this.currentAlpha)
        }
      }
    })
  }

  private applyVisibilityToMesh(mesh: AbstractMesh): void {
    const rootState = this.materialStates.get(mesh.uniqueId.toString())
    if (rootState) {
      const targetVisibility = rootState.originalVisibility * this.currentAlpha
      mesh.visibility = Math.max(0.0, Math.min(1.0, targetVisibility))
    }

    mesh.getChildMeshes().forEach((child) => {
      if (!child.isDisposed()) {
        const childState = this.materialStates.get(child.uniqueId.toString())
        if (childState) {
          const targetVisibility = childState.originalVisibility * this.currentAlpha
          child.visibility = Math.max(0.0, Math.min(1.0, targetVisibility))
        }
      }
    })
  }

  private cleanup(): void {
    if (this.cleaningUp) return
    this.cleaningUp = true

    const mesh = this.attachedNode

    if (this.observer) {
      const scene = mesh?.getScene()
      if (scene) {
        scene.onBeforeRenderObservable.remove(this.observer)
      }
      this.observer = null
    }

    if (mesh && !mesh.isDisposed()) {
      if (DITHER_MODE === DitherMode.Visibility) {
        this.restoreVisibility(mesh)
      } else {
        this.restoreMaterials(mesh)
      }

      mesh.removeBehavior(this)
    }

    this.materialStates.clear()
    this.attachedNode = null
    this.cleaningUp = false
  }

  private restoreVisibility(mesh: AbstractMesh): void {
    const rootState = this.materialStates.get(mesh.uniqueId.toString())
    if (rootState) {
      mesh.visibility = rootState.originalVisibility
      this.restoreMaterialTransparency(rootState.original, rootState.originalAlpha, rootState.originalTransparencyMode)
    }

    mesh.getChildMeshes().forEach((child) => {
      if (!child.isDisposed()) {
        const childState = this.materialStates.get(child.uniqueId.toString())
        if (childState) {
          child.visibility = childState.originalVisibility
          this.restoreMaterialTransparency(childState.original, childState.originalAlpha, childState.originalTransparencyMode)
        }
      }
    })
  }

  private restoreMaterialTransparency(material: Material | null, originalAlpha?: number, originalTransparencyMode?: number): void {
    if (!material) return
    const pbrMat = material as unknown as {
      transparencyMode?: number
      alpha?: number
    }
    if (originalTransparencyMode !== undefined) {
      pbrMat.transparencyMode = originalTransparencyMode
    }
    if (originalAlpha !== undefined) {
      pbrMat.alpha = originalAlpha
    }
  }

  private restoreMaterials(mesh: AbstractMesh): void {
    mesh.material = this.materialStates.get(mesh.uniqueId.toString())?.original ?? null
    mesh.getChildMeshes().forEach((child) => {
      if (!child.isDisposed()) {
        child.material = this.materialStates.get(child.uniqueId.toString())?.original ?? null
      }
    })

    for (const state of this.materialStates.values()) {
      if (state.shader) {
        state.shader.dispose()
      }
    }
  }

  private createShaderMaterials(target: AbstractMesh): void {
    const scene = target.getScene()
    const states = new Map<string, MaterialState>()
    const originalVisibility = target.visibility
    target.visibility = 1.0

    const rootOriginal = target.material
    if (rootOriginal) {
      const hasTexture = this.checkHasTexture(rootOriginal)
      const shader = createDitherMaterial(scene, hasTexture)
      copyTexturesToShaderMaterial(rootOriginal, shader)
      shader.setFloat('alpha', this.currentAlpha)
      target.material = shader
      states.set(target.uniqueId.toString(), { original: rootOriginal, shader, originalVisibility })
    }

    target.getChildMeshes().forEach((child) => {
      const childOriginal = child.material
      if (childOriginal) {
        const childOriginalVisibility = child.visibility
        child.visibility = 1.0
        const hasTexture = this.checkHasTexture(childOriginal)
        const shader = createDitherMaterial(scene, hasTexture)
        copyTexturesToShaderMaterial(childOriginal, shader)
        shader.setFloat('alpha', this.currentAlpha)
        child.material = shader
        states.set(child.uniqueId.toString(), { original: childOriginal, shader, originalVisibility: childOriginalVisibility })
      }
    })

    this.materialStates = states
  }

  private checkHasTexture(material: Material): boolean {
    if ('diffuseTexture' in material && material.diffuseTexture) return true
    if ('albedoTexture' in material && material.albedoTexture) return true
    return false
  }
}

AlphaFadeShader.ts

import { Effect, type Scene, ShaderMaterial } from 'babylonjs'

const VERTEX_SHADER = `
precision highp float;

attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;

uniform mat4 world;
uniform mat4 worldViewProjection;

varying vec2 vUV;
varying vec3 vNormal;
varying vec3 vWorldPos;

void main() {
  gl_Position = worldViewProjection * vec4(position, 1.0);
  vUV = uv;
  vNormal = normalize((world * vec4(normal, 0.0)).xyz);
  vWorldPos = (world * vec4(position, 1.0)).xyz;
}
`

const FRAGMENT_SHADER = `
precision highp float;

varying vec2 vUV;
varying vec3 vNormal;
varying vec3 vWorldPos;

uniform sampler2D diffuseSampler;
uniform float alpha;
uniform float hasDiffuseTexture;
uniform float contrast;
uniform float brightness;

vec3 adjustContrast(vec3 color, float contrast) {
  return (color - 0.5) * contrast + 0.5;
}

vec3 adjustBrightness(vec3 color, float brightness) {
  return color + brightness;
}

void main() {
  vec4 color = vec4(1.0, 1.0, 1.0, 1.0);
  
  if (hasDiffuseTexture > 0.5) {
    color = texture2D(diffuseSampler, vUV);
  }

  color.rgb = adjustContrast(color.rgb, contrast);
  color.rgb = adjustBrightness(color.rgb, brightness);

  gl_FragColor = vec4(color.rgb, color.a * alpha);
}
`

let registered = false

function registerAlphaFadeShader(): void {
  if (registered) return
  Effect.ShadersStore['alphaFadeVertexShader'] = VERTEX_SHADER
  Effect.ShadersStore['alphaFadeFragmentShader'] = FRAGMENT_SHADER
  registered = true
}

const TARGET_ALPHA = Number(import.meta.env.VITE_CAMERA_DITHER_ALPHA) || 0.3

export function createAlphaFadeMaterial(scene: Scene): ShaderMaterial {
  registerAlphaFadeShader()
  const mat = new ShaderMaterial(
    'alphaFadeOcclusion',
    scene,
    { vertex: 'alphaFade', fragment: 'alphaFade' },
    {
      attributes: ['position', 'uv', 'normal'],
      uniforms: ['world', 'worldViewProjection', 'alpha', 'hasDiffuseTexture', 'contrast', 'brightness'],
      samplers: ['diffuseSampler'],
    },
  )
  mat.setFloat('alpha', TARGET_ALPHA)
  mat.setFloat('hasDiffuseTexture', 0.0)
  mat.setFloat('contrast', 0.8)
  mat.setFloat('brightness', 0.02)
  mat.backFaceCulling = true
  mat.needAlphaBlending = () => true
  return mat
}

Amazing updates :smiley:

@onekit I had a question about your video:

Around 23 seconds, I see that the weapon is sort of jittering away from the player. I think I saw this exact bug in my game too (as the character moved away from the origin (0, 0, 0)). The source of the error for me was that the weapon’s position was being set as Float16s instead of Float32s. I’m wondering if this is the same in your case.

If not, maybe Babylon 9.0’s Large World Rendering could be helpful here?

Thanks for pointing out that issue.
I need to switch the implementation from manual weapon positioning to attachToBone .
I’ll fix it so the weapon no longer trails behind the player but is firmly attached to the hand bone.

3 Likes