fancy background is back!

This commit is contained in:
Norman Köhring 2025-03-17 21:56:45 +01:00
parent ecb4c9b54b
commit 8f281fbdf9
8 changed files with 194 additions and 57 deletions

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import type { Block, Direction, Item, InventoryItem } from './types.d'
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue'
import Help from './screens/help.vue'
import Inventory from './screens/inventory.vue'
import Background from './Background.vue'
import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT } from './level/def'
import { getItem, getItemClass } from './level/items'
@ -11,15 +12,15 @@ import createLevel from './level'
import usePlayer from './util/usePlayer'
import useTime from './util/useTime'
import useInput from './util/useInput'
import useLightMap from './util/useLightMap'
import useLightMask from './util/useLightMask'
const { updateTime, time, timeOfDay, clock } = useTime()
const { player, direction, dx, dy, pocket, unpocket } = usePlayer()
const { inputX, inputY, running, paused, help, inventory } = useInput()
const level = createLevel(STAGE_WIDTH + 2, STAGE_HEIGHT + 2)
const lightMapEl = ref<HTMLCanvasElement | undefined>(undefined)
let updateLightMap: ReturnType<typeof useLightMap>
const lightMaskEl = useTemplateRef<HTMLCanvasElement>('light-mask')
let updateLightMap = (() => {}) as ReturnType<typeof useLightMask>
pocket(getItem('tool_shovel_wood'))
pocket(getItem('tool_sword_wood'))
@ -208,15 +209,14 @@ function selectTool(item: InventoryItem) {
}
onMounted(() => {
if (lightMapEl.value) {
const canvas = lightMapEl.value
if (lightMaskEl.value) {
const canvas = lightMaskEl.value
canvas.height = (BLOCK_SIZE + 2) * STAGE_HEIGHT
canvas.width = (BLOCK_SIZE + 2) * STAGE_WIDTH
const ctx = canvas.getContext('2d')!
updateLightMap = useLightMap(ctx, floorX, floorY, tx, ty, time, lightBarrier)
updateLightMap = useLightMask(ctx, floorX, floorY, tx, ty, time, lightBarrier)
} else {
console.warn('lightmap deactivated')
updateLightMap = (() => {}) as ReturnType<typeof useLightMap>
}
lastTick = performance.now()
move(lastTick)
@ -225,6 +225,10 @@ onMounted(() => {
<template>
<div id="field" :class="timeOfDay">
<Background :time :x />
<div id="parallax">
</div>
<div id="blocks" :style="{transform: `translate(${tx}px, ${ty}px)`}">
<template v-for="(row, y) in mapGrid">
<div v-for="(block, x) in row"
@ -255,11 +259,8 @@ onMounted(() => {
</div>
</div>
<canvas id="light-mask" ref="lightMapEl"
:style="{
transform: `translate(${tx}px, ${ty}px)`,
mixBlendMode: paused && debug ? 'normal' : 'multiply',
}"
<canvas id="light-mask" ref="light-mask"
:style="{ transform: `translate(${tx}px, ${ty}px)` }"
/>
<div id="beam" v-if="arriving"></div>
<div id="level-indicator">

View file

@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import useBackground from './util/useBackground'
import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT } from './level/def'
import { calcSunAngle } from './util/useTime'
export interface Props {
time: number
@ -11,8 +12,7 @@ export interface Props {
const props = defineProps<Props>()
const canvas = ref<HTMLCanvasElement | null>(null)
const p = Math.PI / -10
const sunY = computed(() => Math.sin(props.time * p))
const sunY = computed(() => calcSunAngle(props.time))
onMounted(() => {
@ -25,7 +25,10 @@ onMounted(() => {
~~(STAGE_HEIGHT * BLOCK_SIZE / 2.0),
)
watch(props, () => drawBackground(props.x, sunY.value), { immediate: true })
watch(props, () => {
console.log('drawing background', sunY.value)
drawBackground(props.x, sunY.value)
}, { immediate: true })
})
</script>

View file

@ -155,12 +155,14 @@
flex-flow: row wrap;
}
#light-mask {
#parallax, #background, #light-mask {
position: absolute;
top: calc(var(--block-size) * -1);
left: calc(var(--block-size) * -1);
width: calc(100% + var(--block-size) * 2);
height: calc(100% + var(--block-size) * 2);
mix-blend-mode: multiply;
pointer-events: none;
}
#light-mask {
mix-blend-mode: multiply;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View file

@ -18,7 +18,13 @@ function hsl(h: number, s: number, l: number): string {
* @param r: number [44] - the radius of the "sun"
* @returns emissionStrength: number - emission intensity blends over the mountains
*/
function renderGodrays(ctx: CanvasRenderingContext2D, cx: number, cy: number, sunY: number, r = 44) {
function renderGodrays(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
sunY: number,
r = 44,
) {
const w = ctx.canvas.width
const h = ctx.canvas.height
@ -36,11 +42,14 @@ function renderGodrays(ctx: CanvasRenderingContext2D, cx: number, cy: number, su
if (sunY > -30) emissionStrength -= Math.max((30 + sunY) / 5, 0.0)
else if (sunY < -60) emissionStrength += Math.min(1 + (60 + sunY) / 5, 0.0)
emissionGradient.addColorStop(.1, hsl(30, 50, 3.1 * emissionStrength)) // pixels in radius 0 to 4.4 (44 * .1).
emissionGradient.addColorStop(.2, hsl(12, 71, 1.4 * emissionStrength)) // pixels in radius 0 to 4.4 (44 * .1).
// Now paint the gradient all over our godrays canvas.
// pixels in radius 0 to 4.4 (44 * .1)
emissionGradient.addColorStop(.1, hsl(30, 50, 3.1 * emissionStrength))
emissionGradient.addColorStop(.2, hsl(12, 71, 1.4 * emissionStrength))
// Now paint the gradient all over our godrays canvas
ctx.fillRect(0, 0, w, h)
// And set the fillstyle to black, we'll use it to paint our occlusion (mountains).
// And set the fillstyle to black, we'll use it to paint our occlusion (mountains)
ctx.fillStyle = '#000'
return emissionStrength
@ -51,8 +60,12 @@ function renderGodrays(ctx: CanvasRenderingContext2D, cx: number, cy: number, su
* Mountains are made by summing up sine waves with varying frequencies and amplitudes
* The frequencies are prime, to avoid extra repetitions
*/
function calcMountainHeight(pos: number, roughness: number, frequencies = [1721, 947, 547, 233, 73, 31, 7]) {
return frequencies.reduce((height, freq) => height * roughness - Math.cos(freq * pos), 0)
function calcMountainHeight(
pos: number,
roughness: number,
freqs = [1721, 947, 547, 233, 73, 31, 7],
) {
return freqs.reduce((height, freq) => height * roughness - Math.cos(freq * pos), 0)
}
/**
@ -64,7 +77,14 @@ function calcMountainHeight(pos: number, roughness: number, frequencies = [1721,
* @param layers: number - amount of mountain layers for parallax effect
* @param emissionStrength: number - intensity of the godrays
*/
function renderMountains(ctx: CanvasRenderingContext2D, grCtx: CanvasRenderingContext2D, frame: number, sunY: number, layers: number, emissionStrength: number) {
function renderMountains(
ctx: CanvasRenderingContext2D,
grCtx: CanvasRenderingContext2D,
frame: number,
sunY: number,
layers: number,
emissionStrength: number,
) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const grDiv = w / grCtx.canvas.width
@ -127,19 +147,27 @@ function renderSky(ctx: CanvasRenderingContext2D, sunY: number) {
* @param rayQuality: number [8] - The quality of the sunrays (divides the resolution, so higher value means lower quality)
* @param mountainLayers: number [4] - How many layers of mountains are used for parallax effect?
*/
export default function useBackground (canvasEl: HTMLCanvasElement, w: number, h: number, rayQuality = 8, mountainLayers = 4) {
export default function useBackground(
canvasEl: HTMLCanvasElement,
w: number,
h: number,
rayQuality = 8,
mountainLayers = 4,
) {
canvasEl.width = w
canvasEl.height = h
const grW = w / rayQuality
const grH = h / rayQuality
const ctx = canvasEl.getContext('2d')
if (ctx === null) return () => {} // like, how old is your browser?
const grCanvasEl = document.createElement('canvas')
const ctx = canvasEl.getContext('2d')
const grCtx = grCanvasEl.getContext('2d')
if (grCtx === null) return () => {} // for real, how old is it?
if (ctx === null || grCtx === null) {
console.error('BACKGROUND CANVAS ERROR: Failed to set up canvas?!')
return () => {} // for real, how old is it?
}
grCanvasEl.width = grW
grCanvasEl.height = grH
@ -153,7 +181,10 @@ export default function useBackground (canvasEl: HTMLCanvasElement, w: number, h
* @param sunY: number - the position (height) of the sun in the sky
*/
return function drawFrame (frame: number, sunY: number) {
console.log('drawing frame', frame, sunY)
ctx.globalCompositeOperation = grCtx.globalCompositeOperation = 'source-over'
ctx.clearRect(0, 0, w, h)
grCtx.clearRect(0, 0, grW, grH)
const emissionStrength = renderGodrays(grCtx, sunCenterX, sunCenterY, sunY)
renderSky(ctx, sunY)
renderMountains(ctx, grCtx, frame, sunY, mountainLayers, emissionStrength)

View file

@ -3,7 +3,7 @@ import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT } from '../level/def'
type RefOrComputed<T> = Ref<T> | ComputedRef<T>
export default function useLightMap(
export default function useLightMask(
ctx: CanvasRenderingContext2D,
x: RefOrComputed<number>,
y: RefOrComputed<number>,
@ -43,16 +43,29 @@ export default function useLightMap(
}
function drawPlayerLight(sizeMul:number) {
const t = time.value
const playerLight = ctx.createRadialGradient(
playerX - tx.value, playerY - ty.value, 0,
playerX - tx.value, playerY - ty.value, playerLightSize * sizeMul
)
// Add color stops: white in the center to transparent white
playerLight.addColorStop(0.0, "#FFFF");
playerLight.addColorStop(0.4, "#FFFF");
playerLight.addColorStop(0.7, "#FFFA");
playerLight.addColorStop(1, "#FFF1");
// Add color stops for a light around the player
// Night time, lets tone down the light a bit
if (t > 900 || t < 100) {
playerLight.addColorStop(0.7, "#AA7A");
playerLight.addColorStop(1, "#AA70");
// Morning
} else if (t < 150) {
playerLight.addColorStop(0.7, "#CCAA");
playerLight.addColorStop(1, "#CCA0");
// Day (neutral white)
} else {
playerLight.addColorStop(0.7, "#FFFA");
playerLight.addColorStop(1, "#FFF0");
}
// Set the fill style and draw a rectangle
ctx.fillStyle = playerLight;
@ -93,7 +106,7 @@ export default function useLightMap(
return function update() {
// first, throw the world in complete darkness
ctx.fillStyle = '#000'
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, W, H)
// second, find and bring light into the world

58
src/util/useTime.test.ts Normal file
View file

@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { calcTimeOfDay, renderClock, calcSunAngle } from './useTime'
describe('useTime composable', () => {
describe('clock', () => {
it('renders 2:00 at tick 0', () => {
expect(renderClock(0)).toEqual('2:00')
})
it('renders 14:00 at tick 500', () => {
expect(renderClock(500)).toEqual('14:00')
})
it('does not break on meanless values', () => {
expect(renderClock(9500)).toBeDefined()
expect(renderClock(-500)).toBeDefined()
})
})
describe('time of day', () => {
it('sets the correct time of day', () => {
expect(calcTimeOfDay(0)).toEqual('night')
expect(calcTimeOfDay(900)).toEqual('night')
expect(calcTimeOfDay(100)).toEqual('morning0')
expect(calcTimeOfDay(145)).toEqual('morning1')
expect(calcTimeOfDay(200)).toEqual('morning2')
expect(calcTimeOfDay(700)).toEqual('evening0')
expect(calcTimeOfDay(800)).toEqual('evening1')
expect(calcTimeOfDay(850)).toEqual('evening2')
expect(calcTimeOfDay(300)).toEqual('day')
expect(calcTimeOfDay(400)).toEqual('day')
expect(calcTimeOfDay(555)).toEqual('day')
})
})
describe('sun angle', () => {
it('returns -10 degrees over night', () => {
expect(calcSunAngle(900)).toEqual(-10)
expect(calcSunAngle(945)).toEqual(-10)
expect(calcSunAngle(0)).toEqual(-10)
expect(calcSunAngle(57)).toEqual(-10)
})
it('returns -90 degrees over day', () => {
expect(calcSunAngle(250)).toEqual(-90)
expect(calcSunAngle(300)).toEqual(-90)
expect(calcSunAngle(557)).toEqual(-90)
expect(Math.round(calcSunAngle(699))).toEqual(-90)
})
it('raises in the morning', () => {
expect(calcSunAngle(100)).toEqual(-20)
expect(calcSunAngle(150)).toEqual(-45)
expect(calcSunAngle(200)).toEqual(-70)
expect(calcSunAngle(240)).toEqual(-90)
})
it('sinks in the evening', () => {
expect(calcSunAngle(700)).toEqual(-90)
expect(calcSunAngle(750)).toEqual(-70)
expect(calcSunAngle(800)).toEqual(-50)
expect(Math.round(calcSunAngle(899))).toEqual(-10)
})
})
})

View file

@ -1,30 +1,59 @@
import { ref, computed } from 'vue'
type TimeOfDay =
| 'day'
| 'night'
| 'morning0'
| 'morning1'
| 'morning2'
| 'evening0'
| 'evening1'
| 'evening2'
export function calcTimeOfDay(tick: number): TimeOfDay {
if (tick >= 900 || tick < 80) return 'night'
if (tick >= 80 && tick < 120) return 'morning0'
if (tick >= 120 && tick < 150) return 'morning1'
if (tick >= 150 && tick < 240) return 'morning2'
if (tick >= 700 && tick < 800) return 'evening0'
if (tick >= 800 && tick < 850) return 'evening1'
if (tick >= 850 && tick < 900) return 'evening2'
return 'day'
}
export function renderClock(tick: number): string {
const t = tick * 86.4 // 1000 ticks to 86400 seconds (per day)
const h = ~~(t / 3600.0)
const m = ~~((t / 3600.0 - h) * 60.0)
return `${(h + 2) % 24}:${m < 10 ? '0' : ''}${m}`
}
function calcProgress(tick: number, start: number, end: number, span: number): number {
return (tick - start) / (end - start) * span
}
// calculates the suns angle from -90 (top, at noon), to 0 (at midnight)
export function calcSunAngle(tick: number): number {
// night time: -10 degrees fixed
if (tick >= 900 || tick < 80) return -10
// sunrise: gradually move from -10 to -90, by mapping 80 -> 240 to -10 -> -90
if (tick >= 80 && tick < 240) return -10 - calcProgress(tick, 80, 240, 80)
// sundawn: gradually move from -90 to -10, by mapping 700 -> 900 to -90 -> -10
if (tick >= 700 && tick < 900) return -90 + calcProgress(tick, 700, 900, 80)
// day time: -90 degrees fixed
return -90
}
export default function useTime() {
// the day is split in 1000 parts, so we start in the morning
const time = ref(230)
const time = ref(250)
function updateTime() {
time.value = (time.value + 0.1) % 1000
}
const timeOfDay = computed(() => {
if (time.value >= 900 || time.value < 80) return 'night'
if (time.value >= 80 && time.value < 120) return 'morning0'
if (time.value >= 120 && time.value < 150) return 'morning1'
if (time.value >= 150 && time.value < 240) return 'morning2'
if (time.value >= 700 && time.value < 800) return 'evening0'
if (time.value >= 800 && time.value < 850) return 'evening1'
if (time.value >= 850 && time.value < 900) return 'evening2'
return 'day'
})
const clock = computed(() => {
const t = time.value * 86.4 // 1000 ticks to 86400 seconds (per day)
const h = ~~(t / 3600.0)
const m = ~~((t / 3600.0 - h) * 60.0)
return `${(h + 2) % 24}:${m < 10 ? '0' : ''}${m}`
})
const timeOfDay = computed(() => calcTimeOfDay(time.value))
const clock = computed(() => renderClock(time.value))
return { time, updateTime, timeOfDay, clock }
}