diff --git a/src/App.vue b/src/App.vue index 788cd73..fe1ad38 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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"> diff --git a/src/Background.vue b/src/Background.vue index 2ec213a..99c55e2 100644 --- a/src/Background.vue +++ b/src/Background.vue @@ -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> diff --git a/src/assets/field.css b/src/assets/field.css index ea85618..f824ed2 100644 --- a/src/assets/field.css +++ b/src/assets/field.css @@ -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; +} diff --git a/src/assets/mask.png b/src/assets/mask.png deleted file mode 100644 index 9a04caf..0000000 Binary files a/src/assets/mask.png and /dev/null differ diff --git a/src/util/useBackground.ts b/src/util/useBackground.ts index 962a20d..d8f6b00 100644 --- a/src/util/useBackground.ts +++ b/src/util/useBackground.ts @@ -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) diff --git a/src/util/useLightMap.ts b/src/util/useLightMask.ts similarity index 83% rename from src/util/useLightMap.ts rename to src/util/useLightMask.ts index 16bcf15..4c3a23e 100644 --- a/src/util/useLightMap.ts +++ b/src/util/useLightMask.ts @@ -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 diff --git a/src/util/useTime.test.ts b/src/util/useTime.test.ts new file mode 100644 index 0000000..a2e05ee --- /dev/null +++ b/src/util/useTime.test.ts @@ -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) + }) + }) +}) diff --git a/src/util/useTime.ts b/src/util/useTime.ts index b817fc3..70c94cc 100644 --- a/src/util/useTime.ts +++ b/src/util/useTime.ts @@ -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 } }