Compare commits

...

10 Commits

Author SHA1 Message Date
Norman Köhring 3c8b289310 debugging highlights 1 year ago
Norman Köhring 1b3360862f clickable quick select and quick-select reorder 1 year ago
Norman Köhring f24c2cd191 item quick select 1 year ago
Norman Köhring cc2b787421 put/build blocks 1 year ago
Norman Köhring d1d2b3fbf0 prepare for putting/building blocks 1 year ago
Norman Köhring eee378bb7b block drops into inventory 1 year ago
Norman Köhring 9d3912f98f fix lighting and this one weird bug in player changes when ascending 1 year ago
Norman Köhring fc05b00775 first steps towards real inventory 1 year ago
Norman Köhring fb3279fb13 remove unused function 1 year ago
Norman Köhring 656927eee1 draw light instead of shadows 1 year ago

3
.gitignore vendored

@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# old source code for reference?!
src/old

@ -3,27 +3,26 @@ import { ref, computed, onMounted } from 'vue'
import Help from './screens/help.vue'
import Inventory from './screens/inventory.vue'
import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT, type Block, blockTypes } from './level/def'
import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT, type Block } from './level/def'
import { getItem, getItemClass } from './level/items'
import createLevel from './level'
import useTime from './util/useTime'
import useInput from './util/useInput'
import usePlayer from './util/usePlayer'
import usePlayer, { type InventoryItem } from './util/usePlayer'
import useLightMap from './util/useLightMap'
const { updateTime, time, timeOfDay, clock } = useTime()
const { player, direction, dx, dy } = usePlayer()
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>
player.inventory.push(
{ name: 'Shovel', type: 'tool', icon: 'shovel', quality: 'bronze', amount: 1 },
{ name: 'Sword', type: 'weapon', icon: 'sword', quality: 'bronze', amount: 2 },
{ name: 'Pick Axe', type: 'tool', icon: 'pick', quality: 'bronze', amount: 1 },
)
pocket({ name: 'Shovel', type: 'tool', icon: 'shovel', quality: 'wood' })
pocket({ name: 'Sword', type: 'weapon', icon: 'sword', quality: 'wood' })
pocket({ name: 'Pick Axe', type: 'tool', icon: 'pick', quality: 'wood' })
let animationFrame = 0
let lastTick = 0
@ -74,14 +73,56 @@ const blocked = computed(() => {
// TODO
const damagedBlocks = ref([])
function dig(blockX: number, blockY: number, oldBlockType: BlockType) {
function dig(blockX: number, blockY: number, block: Block) {
// can only dig in player proximity
if (Math.abs(player.x - blockX) > 2 || Math.abs(player.y - blockY) > 2) return
// finally dig that block
// TODO: damage blocks first
level.change({
type: 'exchange',
x: floorX.value + blockX,
y: floorY.value + blockY,
newType: 'air'
})
// anything to pick up?
if (block.drops) {
const dropItem = getItem(block.drops)
if (dropItem) pocket(dropItem)
}
}
function build(blockX: number, blockY: number, block: InventoryItem) {
const blockToBuild = block.builds
// the block doesn't do anything
if (!blockToBuild) return
level.change({
type: 'exchange',
x: floorX.value + blockX,
y: floorY.value + blockY,
newType: blockToBuild
})
const newAmount = unpocket(block)
if (newAmount < 1) inventorySelection.value = player.inventory[0]
}
function interactWith(blockX: number, blockY: number, block: Block) {
// § 4 ArbZG
if (paused.value) return
// TODO: temporary filter
if (oldBlockType === 'air' || oldBlockType === 'cave') return
// when we finally dig that block
level.change({ type: 'exchange', x: floorX.value + blockX, y: floorY.value + blockY, newType: 'air' })
const blockInHand = inventorySelection.value.type === 'block'
const toolInHand = inventorySelection.value.type === 'tool'
const emptyBlock = block.type === 'air' || block.type === 'cave'
// put the selected block
if (blockInHand && emptyBlock) {
build(blockX, blockY, inventorySelection.value)
// dig a block with shovel or pick axe
} else if (toolInHand && !emptyBlock) {
dig(blockX, blockY, block)
}
// This feels like cheating, but it makes Vue recalculate floorX
// which then recalculates the blocks, so that the changes are
@ -97,12 +138,14 @@ const move = (thisTick: number): void => {
// do nothing when paused
if (paused.value) {
lastTick = thisTick // reset tick, to avoid huge tickDelta
// reset tick, to avoid tickDelta and resulting character teleport
lastTick = thisTick
return
}
const tickDelta = thisTick - lastTick
lastTimeUpdate += tickDelta
// update in-game time every 60ms by 0.1
// then a day needs 10000 updates, and it takes about 10 minutes
if (lastTimeUpdate > 60) {
@ -148,21 +191,8 @@ const move = (thisTick: number): void => {
lastTick = thisTick
}
function calcBrightness(level: number, row: number) {
const barrier = lightBarrier.value[row]
const barrierLeft = lightBarrier.value[row - 1]
const barrierRight = lightBarrier.value[row + 1]
let delta = barrier - level - (floorY.value - 3)
const deltaL = Math.min(3, barrierLeft - level - (floorY.value - 3))
const deltaR = Math.min(3, barrierRight - level - (floorY.value - 3))
if (delta > 3) delta = 3
else if (delta < 0) delta = 0
if (deltaR > delta || deltaL > delta) delta = Math.max(deltaL, deltaR) - 1
return `sun-${delta}`
function selectTool(item: InventoryItem) {
inventorySelection.value = item
}
onMounted(() => {
@ -183,8 +213,10 @@ onMounted(() => {
<div id="blocks" :style="{transform: `translate(${tx}px, ${ty}px)`}">
<template v-for="(row, y) in rows">
<div v-for="(block, x) in row"
:class="['block', block.type]"
@click="dig(x, y, block.type)"
:class="['block', block.type, {
highlight: x === player.x && y == player.y
}]"
@click="interactWith(x, y, block)"
/>
</template>
</div>
@ -198,7 +230,7 @@ onMounted(() => {
</div>
<div class="arms">
<div v-if="inventorySelection"
:class="['item', `${inventorySelection.type}-${inventorySelection.icon}-${inventorySelection.quality}`]"
:class="['item', getItemClass(inventorySelection)]"
></div>
</div>
</div>
@ -213,7 +245,7 @@ onMounted(() => {
<Inventory :shown="inventory"
:items="player.inventory"
@selection="inventorySelection = $event"
@selection="selectTool($event)"
/>
<Help v-show="help" />
</div>

@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import useBackground from './util/useBackground'
import { BLOCK_SIZE, STAGE_WIDTH, STAGE_HEIGHT } from './level/def'
export interface Props {
time: number
x: number
}
const props = defineProps<Props>()
const canvas = ref<HTMLCanvasElement | null>(null)
const p = Math.PI / -10
const sunY = computed(() => Math.sin(props.time * p))
onMounted(() => {
const canvasEl = canvas.value
if (canvasEl === null) return
const drawBackground = useBackground(
canvasEl,
~~(STAGE_WIDTH * BLOCK_SIZE / 2.0),
~~(STAGE_HEIGHT * BLOCK_SIZE / 2.0),
)
watch(props, () => drawBackground(props.x, sunY.value), { immediate: true })
})
</script>
<template>
<canvas ref="canvas" id="background"></canvas>
</template>

@ -1,6 +1,8 @@
.block, #player {
.block,
#player {
transition: filter .5s linear;
}
.block {
flex: 0 0 auto;
width: var(--block-size);
@ -10,6 +12,7 @@
background-position: center;
background-repeat: no-repeat;
}
.block::after {
content: '';
position: absolute;
@ -23,37 +26,120 @@
background-size: cover;
}
.block.damage-0::after { background-position-x: 0px; }
.block.damage-1::after { background-position-x: calc(var(--block-size) * -1); }
.block.damage-2::after { background-position-x: calc(var(--block-size) * -2); }
.block.damage-3::after { background-position-x: calc(var(--block-size) * -3); }
.block.damage-4::after { background-position-x: calc(var(--block-size) * -4); }
.block.damage-5::after { background-position-x: calc(var(--block-size) * -5); }
.block.damage-6::after { background-position-x: calc(var(--block-size) * -6); }
.block.damage-0::after {
background-position-x: 0px;
}
.block.damage-1::after {
background-position-x: calc(var(--block-size) * -1);
}
.block.damage-2::after {
background-position-x: calc(var(--block-size) * -2);
}
.block.damage-3::after {
background-position-x: calc(var(--block-size) * -3);
}
.block.damage-4::after {
background-position-x: calc(var(--block-size) * -4);
}
.block.damage-5::after {
background-position-x: calc(var(--block-size) * -5);
}
.block.damage-6::after {
background-position-x: calc(var(--block-size) * -6);
}
.block.grass {
background-image: url(/Tiles/dirt_grass.png);
}
.block.treeCrown,
.block.treeLeaves {
background-image: url(/Tiles/leaves_transparent.png);
}
.block.treeTrunk {
background-image: url(/Tiles/trunk_mid.png);
}
.block.treeRoot {
background-image: url(/Tiles/trunk_bottom.png);
}
.block.soil {
background-image: url(/Tiles/dirt.png);
}
.block.soilGravel {
background-image: url(/Tiles/gravel_dirt.png);
}
.block.stoneGravel {
background-image: url(/Tiles/gravel_stone.png);
}
.block.stone {
background-image: url(/Tiles/stone.png);
}
.block.bedrock {
background-image: url(/Tiles/greystone.png);
}
.block.cave {
background-color: #000;
}
.block.brickWall {
background-image: url(/Tiles/brick_grey.png);
}
#field .block:hover,
#field .block.block.highlight {
filter: brightness(1.2) grayscale(1.0);
outline: 1px solid white;
z-index: 10;
}
.block.grass { background-image: url(/Tiles/dirt_grass.png); }
.morning0 .block,
.morning0 #player {
filter: saturate(50%);
}
.morning1 .block,
.morning1 #player {
filter: saturate(100%);
}
.block.treeCrown, .block.treeLeaves { background-image: url(/Tiles/leaves_transparent.png); }
.block.treeTrunk { background-image: url(/Tiles/trunk_mid.png); }
.block.treeRoot { background-image: url(/Tiles/trunk_bottom.png); }
.morning2 .block,
.morning2 #player {
filter: saturate(120%);
}
.block.soil { background-image: url(/Tiles/dirt.png); }
.block.soilGravel { background-image: url(/Tiles/gravel_dirt.png); }
.block.stoneGravel { background-image: url(/Tiles/gravel_stone.png); }
.block.stone { background-image: url(/Tiles/stone.png); }
.block.bedrock { background-image: url(/Tiles/greystone.png); }
.block.cave { background-color: #000; }
#field .block:hover { outline: 1px solid white; z-index: 10; }
.evening0 .block,
.evening0 #player {
filter: saturate(90%);
}
.morning0 .block, .morning0 #player {filter: saturate(50%); }
.morning1 .block, .morning1 #player { filter: saturate(100%); }
.morning2 .block, .morning2 #player { filter: saturate(120%); }
.evening1 .block,
.evening1 #player {
filter: saturate(70%);
}
.evening0 .block, .evening0 #player { filter: saturate(90%); }
.evening1 .block, .evening1 #player { filter: saturate(70%); }
.evening2 .block, .evening2 #player { filter: saturate(50%); }
.evening2 .block,
.evening2 #player {
filter: saturate(50%);
}
.night .block, .night #player { filter: saturate(30%); }
.night .block,
.night #player {
filter: saturate(30%);
}
#blocks {
position: absolute;
@ -73,4 +159,4 @@
height: calc(100% + var(--block-size) * 2);
mix-blend-mode: multiply;
pointer-events: none;
}
}

@ -1,9 +1,7 @@
.item.tool-shovel-bronze {
background-image: url("/Items/shovel_bronze.png");
}
.item.weapon-sword-bronze {
background-image: url("/Items/sword_bronze.png");
}
.item.tool-pick-bronze {
background-image: url("/Items/pick_bronze.png");
}
.item.tool-shovel-wood { background-image: url("/Items/shovel_bronze.png"); }
.item.weapon-sword-wood { background-image: url("/Items/sword_bronze.png"); }
.item.tool-pick-wood { background-image: url("/Items/pick_bronze.png"); }
.item.block-wood { background-image: url("/Tiles/wood.png"); }
.item.block-dirt { background-image: url("/Tiles/dirt.png"); }
.item.block-stone { background-image: url("/Tiles/stone.png"); }

@ -1,3 +1,5 @@
import type { DropItem } from './items'
export const BLOCK_SIZE = 64 // each block is 64̨̣̌̇x64 pixel in size and equals 1m
export const RECIPROCAL = 1 / BLOCK_SIZE
@ -8,12 +10,13 @@ export const STAGE_HEIGHT = 12 // 12*64 = 768 pixel high stage
export const GRAVITY = 10 // blocks per second
export type Block = {
type: string,
hp: number,
walkable: boolean,
climbable?: boolean,
transparent?: boolean,
illuminated?: boolean,
type: string, // what is it?
hp: number, // how long do I need to hit it?
walkable: boolean, // can I walk through it?
climbable?: boolean, // can I climb it?
transparent?: boolean, // can I see through it?
illuminated?: boolean, // is it glowing?
drops?: DropItem, // what do I get, when loot it?
}
export type BlockType =
@ -21,22 +24,26 @@ export type BlockType =
| 'treeCrown' | 'treeLeaves' | 'treeTrunk' | 'treeRoot'
| 'soil' | 'soilGravel' | 'stone' | 'stoneGravel'
| 'bedrock' | 'cave'
| 'brickWall'
export const blockTypes: Record<BlockType, Block> = {
air: { type: 'air', hp: Infinity, walkable: true, transparent: true, illuminated: true },
grass: { type: 'grass', hp: 5, walkable: false },
treeCrown: { type: 'treeCrown', hp: 1, walkable: true, transparent: true },
treeLeaves: { type: 'treeLeaves', hp: 1, walkable: true, transparent: true },
treeTrunk: { type: 'treeTrunk', hp: 10, walkable: true, climbable: true, transparent: true },
treeRoot: { type: 'treeRoot', hp: 10, walkable: true, climbable: true },
soil: { type: 'soil', hp: 5, walkable: false },
soilGravel: { type: 'soilGravel', hp: 5, walkable: false },
stoneGravel: { type: 'stoneGravel', hp: 10, walkable: false },
stone: { type: 'stone', hp: 10, walkable: false },
bedrock: { type: 'bedrock', hp: 25, walkable: false },
// Transparent Blocks
air: { type: 'air', hp: Infinity, walkable: true, transparent: true },
cave: { type: 'cave', hp: Infinity, walkable: true, transparent: true },
// Tree Parts
treeCrown: { type: 'treeCrown', hp: 1, walkable: true, transparent: true, drops: 'leaves' },
treeLeaves: { type: 'treeLeaves', hp: 1, walkable: true, transparent: true, drops: 'leaves' },
treeTrunk: { type: 'treeTrunk', hp: 10, walkable: true, climbable: true, transparent: true, drops: 'wood' },
treeRoot: { type: 'treeRoot', hp: 10, walkable: true, climbable: true, drops: 'wood' },
// Opaque Natural Blocks
grass: { type: 'grass', hp: 5, walkable: false, drops: 'dirt' },
soil: { type: 'soil', hp: 5, walkable: false, drops: 'dirt' },
soilGravel: { type: 'soilGravel', hp: 5, walkable: false, drops: 'gravel' },
stoneGravel: { type: 'stoneGravel', hp: 10, walkable: false, drops: 'gravel' },
stone: { type: 'stone', hp: 10, walkable: false, drops: 'stone' },
bedrock: { type: 'bedrock', hp: 25, walkable: false, drops: 'stone' },
// Built Blocks
brickWall: { type: 'brickWall', hp: 25, walkable: false, drops: 'stone' },
}
export const level = {

@ -50,7 +50,7 @@ export default function createLevel(width: number, height: number, seed = 'extre
if (changes) {
const maxLevel = levelOffset + height
changes.forEach(c => {
if (c.type !== 'exchange' || c.y < levelOffset || c.y > maxLevel) return
if (c.type !== 'exchange' || c.y < levelOffset || c.y >= maxLevel) return
_grid[c.y - levelOffset][c.x - columnOffset] = blockTypes[c.newType]
})
}

@ -0,0 +1,61 @@
import type { BlockType } from './def'
import type { InventoryItem } from '../util/usePlayer'
export type ItemQuality = 'wood' | 'iron' | 'silver' | 'gold' | 'diamond'
export type ItemType = 'tool' | 'weapon' | 'block' | 'ore'
export type DropItem =
| 'Shovel' | 'Pick Axe' | 'Sword'
| 'leaves' | 'dirt' | 'wood' | 'stone' | 'gravel'
| 'coal' | 'iron' | 'silver' | 'gold' | 'ruby' | 'diamond' | 'emerald'
export interface Item {
name: DropItem
type: ItemType
icon: string
hasQuality?: boolean
builds?: BlockType
}
export const items: Item[] = [
{ name: 'Shovel', type: 'tool', icon: 'shovel', hasQuality: true },
{ name: 'Pick Axe', type: 'tool', icon: 'pick', hasQuality: true },
{ name: 'Sword', type: 'weapon', icon: 'sword', hasQuality: true },
{ name: 'leaves', type: 'block', icon: 'leaves', builds: 'treeLeaves' },
{ name: 'dirt', type: 'block', icon: 'dirt', builds: 'soil' },
{ name: 'wood', type: 'block', icon: 'wood', builds: 'treeTrunk' },
{ name: 'stone', type: 'block', icon: 'stone', builds: 'brickWall' },
{ name: 'gravel', type: 'block', icon: 'stone' }, // TODO
{ name: 'coal', type: 'ore', icon: 'ore_coal' },
{ name: 'iron', type: 'ore', icon: 'ore_iron' },
{ name: 'silver', type: 'ore', icon: 'ore_silver' },
{ name: 'gold', type: 'ore', icon: 'ore_gold' },
{ name: 'ruby', type: 'ore', icon: 'ore_ruby' },
{ name: 'diamond', type: 'ore', icon: 'ore_diamond' },
{ name: 'emerald', type: 'ore', icon: 'ore_emerald' },
]
export const damage: Record<ItemQuality, number> = {
wood: 1,
iron: 2,
silver: 3,
gold: 5,
diamond: 8,
}
export function getItem(name: string, quality = null) {
const item = items.find(i => i.name === name)
if (item) {
return {
...item,
quality,
}
}
}
export function getItemClass(item: InventoryItem) {
if (item.quality) return `${item.type}-${item.icon}-${item.quality}`
return `${item.type}-${item.icon}`
}

@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import { getItemClass } from '../level/items'
import type { InventoryItem } from '../util/usePlayer'
export interface Props {
items: InventoryItem[]
@ -21,13 +23,54 @@ const inventory = computed(() => {
return inventory
})
const quickSelect = ref([0, 1, 2, 3, 4, 5, 6, 7, 8])
const hoveredItem = ref<number | null>(null)
function setSelection(i: number) {
selectedIndex.value = i
emit('selection', inventory.value[i])
}
function quickSelection(i: number) {
if (props.shown && hoveredItem.value !== null) {
quickSelect.value[i] = hoveredItem.value
} else {
setSelection(i)
}
}
function handleQuickSelect(event: KeyboardEvent) {
switch (event.key) {
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
quickSelection(quickSelect.value[parseInt(event.key) - 1])
break
}
}
document.addEventListener('keypress', handleQuickSelect)
onUnmounted(() => document.removeEventListener('keypress', handleQuickSelect))
</script>
<template>
<section id="quick-select">
<ol>
<li v-for="idx, i in quickSelect"
class="item"
:class="inventory[idx] && getItemClass(inventory[idx])"
@click="setSelection(idx)"
>
<span>&nbsp;{{ i + 1 }}&nbsp;</span>
</li>
</ol>
</section>
<section id="inventory" class="screen" :class="{ shown }">
<header>
<h1>Inventory</h1>
@ -39,60 +82,98 @@ function setSelection(i: number) {
<i>(empty)</i>
</li>
<li v-else
:class="['item', `${item.type}-${item.icon}-${item.quality}`, {
:class="['item', getItemClass(item), {
selected: selectedIndex === i
}]"
@click="setSelection(i)"
@mouseover="hoveredItem = i"
@mouseout="hoveredItem = null"
>
<b>
{{ item.name }}
<template v-if="item.amount > 1">
(×{{ item.amount }})
</template>
<template v-if="quickSelect.indexOf(i) >= 0">
[{{ quickSelect.indexOf(i) + 1 }}]
</template>
</b>
</li>
</template>
</ol>
<footer>
<p><i><small>Hover item and press a number to add to quick select.</small></i></p>
</footer>
</section>
</template>
<style scoped>
#inventory {
width: 346px;
transition: transform .3s ease-out;
transform: translate(-50vw);
}
#inventory.shown {
transform: translate(0px);
}
ol {
list-style: none;
display: flex;
flex-flow: row wrap;
gap: 1em;
margin: 0;
padding: 0;
}
li {
position: relative;
display: inline-block;
width: 100px;
height: 100px;
text-align: center;
border: 2px solid white;
border-radius: 6px;
background: transparent center no-repeat;
background-size: contain;
cursor: pointer;
}
li:not(.empty):hover, li.selected {
background-color: #FFCA;
}
li.empty {
cursor: not-allowed;
}
li > i, li > b {
#quick-select {
position: absolute;
top: 12px;
left: 12px;
padding: 6px;
background: #0006;
border-radius: 6px;
}
#quick-select ol {
gap: 6px;
}
#quick-select li {
width: 32px;
height: 32px;
background-color: #000;
color: #CCC;
text-align: right;
}
#quick-select li > span {
display: inline-block;
margin-top: 22px;
background: #0008;
border-radius: 2px;
line-height: 1;
}
#inventory {
width: 346px;
transition: transform .3s ease-out;
transform: translate(-50vw);
}
#inventory.shown {
transform: translate(0px);
}
#inventory ol {
gap: 1em;
}
#inventory li {
width: 100px;
height: 100px;
border: 2px solid white;
border-radius: 6px;
text-align: center;
}
#inventory li:not(.empty):hover, li.selected {
background-color: #FFCA;
}
#inventory li > i, li > b {
position: absolute;
bottom: 0;
right: 1px;

@ -0,0 +1,186 @@
/**
* hsl - creates hsl color string from h, s and l values
* @param h: number - hue
* @param s: number - saturation
* @param l: number - lightness
*/
function hsl(h: number, s: number, l: number): string {
return `hsl(${h}, ${s}%, ${l}%)`
}
/**
* render godrays
* @param ctx: CanvasRenderingContext2D - where to draw
* @param cx: number - x-axis center of the "sun"
* @param cy: number - y-axis center of the "sun"
* @param sunY: number - the position (height) of the "sun" in the sky
* @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) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const emissionGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
ctx.fillStyle = emissionGradient
// Now we addColorStops. This needs to be a dark gradient because our
// godrays effect will basically overlay it on top of itself many many times,
// so anything lighter will result in lots of white.
// If you're not space-bound you can add another stop or two, maybe fade out to black,
// but this actually looks good enough.
// a black "gradient" means no emission, so we fade to black as transition to night or day
let emissionStrength = 1.0
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.
ctx.fillRect(0, 0, w, h)
// And set the fillstyle to black, we'll use it to paint our occlusion (mountains).
ctx.fillStyle = '#000'
return emissionStrength
}
/**
* calculate mountain height
* 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)
}
/**
* render mountains
* @param ctx: CanvasRenderingContext2D - where to draw
* @param grCtx: CanvasRenderingContext2D - for drawing mountain shadows on the godray canvas
* @param frame: number - current frame (position on the x axis)
* @param sunY: number - position (height) of the "sun" in the sky
* @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) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const grDiv = w / grCtx.canvas.width
for (let i = 0; i < layers; i++) {
// Set the main canvas fillStyle to a shade of green-brown with variable lightness
// depending on sunY and depth
ctx.fillStyle = sunY > -60
? hsl(5, 23, 33*emissionStrength - i*6*emissionStrength)
: hsl(220 - i*40, 23, 33-i*6)
for (let x = w; x--;) {
// Ok, I don't really remember the details here, basically the (frame+frame*i*i) makes the
// near mountains move faster than the far ones. We divide by large numbers because our
// mountains repeat at position 1/7*Math.PI*2 or something like that...
const pos = (frame * 2 * i**2) / 1000 + x / 2000
// Make further mountains more jagged, adds a bit of realism and also makes the godrays
// look nicer.
const roughness = i / 19 - .5
// 128 is the middle, i * 25 moves the nearer mountains lower on the screen.
let y = 128 + i * 25 + calcMountainHeight(pos, roughness) * 45
// Paint a 1px-wide rectangle from the mountain's top to below the bottom of the canvas.
ctx.fillRect(x, y, 1, h)
// Paint the same thing in black on the godrays emission canvas, which is 1/4 the size,
// and move it one pixel down (otherwise there can be a tiny underlit space between the
// mountains and the sky).
grCtx.fillRect(x/grDiv, y/grDiv+1, 1, h)
}
}
}
/**
* render sky
* @param ctx: CanvasRenderingContext2D - where to draw
* @param sunY: number - the position (height) of the "sun" in the sky
*/
function renderSky(ctx: CanvasRenderingContext2D, sunY: number) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const skyGradient = ctx.createLinearGradient(0, 0, 0, h)
const skyHue = 360 + sunY // hue from blue to red, depending on the suns position
const skySaturation = 100 + sunY // less saturation at day so that the red fades away
const skyLightness = Math.min(sunY * -1 - 10, 55) // darker at night
const skyHSLTop = `hsl(220, 70%, ${skyLightness}%)`
const skyHSLBottom = `hsl(${skyHue}, ${skySaturation}%, ${skyLightness}%)`
skyGradient.addColorStop(0, skyHSLTop)
skyGradient.addColorStop(.7, skyHSLBottom)
ctx.fillStyle = skyGradient
ctx.fillRect(0, 0, w, h)
}
/**
* useBackground
* @param canvasEl: HTMLCanvasElement - the canvas to draw the background on.
* @param w: number - the (pixel) width of the canvas. The element itself can have a different width.
* @param h: number - the (pixel) height of the canvas. The element itself can have a different height.
* @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) {
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 grCtx = grCanvasEl.getContext('2d')
if (grCtx === null) return // like, how old is your browser?
grCanvasEl.width = grW
grCanvasEl.height = grH
const sunCenterX = grCanvasEl.width / 2
const sunCenterY = grCanvasEl.height / 2
/**
* draw one frame of the background
* @param frame: number - the position on the x axis, to calculate the paralax background
* @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)
const emissionStrength = renderGodrays(grCtx, sunCenterX, sunCenterY, sunY)
renderSky(ctx, sunY)
renderMountains(ctx, grCtx, frame, sunY, mountainLayers, emissionStrength)
// The godrays are generated by adding up RGB values, gCt is the bane of all js golfers -
// globalCompositeOperation. Set it to 'lighter' on both canvases.
ctx.globalCompositeOperation = grCtx.globalCompositeOperation = 'lighter'
// NOW - let's light this m**f** up! We'll make several passes over our emission canvas,
// each time adding an enlarged copy of it to itself so at the first pass we get 2 copies, then 4,
// then 8, then 16 etc... We square our scale factor at each iteration.
for (let scaleFactor = 1.07; scaleFactor < 5; scaleFactor *= scaleFactor) {
// The x, y, width and height arguments for drawImage keep the light source in the same
// spot on the enlarged copy. It basically boils down to multiplying a 2D matrix by itself.
// There's probably a better way to do this, but I couldn't figure it out.
// For reference, here's an AS3 version (where BitmapData:draw takes a matrix argument):
// https://github.com/yonatan/volumetrics/blob/d3849027213e9499742cc4dfd2838c6032f4d9d3/src/org/zozuar/volumetrics/EffectContainer.as#L208-L209
grCtx.drawImage(
grCanvasEl,
(grW - grW * scaleFactor) / 2,
(grH - grH * scaleFactor) / 2 - sunY * scaleFactor + sunY,
grW * scaleFactor,
grH * scaleFactor
)
}
// Draw godrays to output canvas (whose globalCompositeOperation is already set to 'lighter').
ctx.drawImage(grCanvasEl, 0, 0, w, h);
}
}

@ -12,10 +12,11 @@ export default function useLightMap(
) {
const W = ((STAGE_WIDTH + 2) * BLOCK_SIZE)
const H = ((STAGE_HEIGHT + 2) * BLOCK_SIZE)
const B = BLOCK_SIZE - 4 // no idea why there is a difference, but it is 4px
const playerX = (W - BLOCK_SIZE) / 2 + BLOCK_SIZE / 4
const playerY = H / 2 - BLOCK_SIZE / 2
const playerLightSize = BLOCK_SIZE * 1.8
const playerX = (W - B) / 2 + B / 4
const playerY = H / 2 - B / 2
const playerLightSize = B * 1.8
function getAmbientLightColor() {
const t = time.value
@ -45,7 +46,7 @@ export default function useLightMap(
playerX - tx.value, playerY - ty.value, playerLightSize * sizeMul
)
// Add three color stops
// Add color stops: white in the center to transparent white
playerLight.addColorStop(0.0, "#FFFF");
playerLight.addColorStop(1, "#FFF0");
@ -54,28 +55,51 @@ export default function useLightMap(
ctx.fillRect(0, 0, W, H)
}
function drawShadows() {
function drawLights() {
// used for everything above ground
const ambientLight = getAmbientLightColor()
const surroundingLight = ambientLight.slice(-2)
const barrier = lightBarrier.value
ctx.fillStyle = '#000A'
for (let col = 0; col < W / BLOCK_SIZE; col++) {
const level = (barrier[col] - y.value) * BLOCK_SIZE
const sw = BLOCK_SIZE * 0.935
const sh = H - level
ctx.fillStyle = ambientLight
for (let col = 0; col < W / B; col++) {
const level = (barrier[col] - y.value) * B
const sw = B
const sh = level
const sx = col * sw
const sy = level * 0.995
const sy = 0
ctx.fillRect(sx, sy, sw, sh)
ctx.fillRect(sx, sy + 20, sw, sh)
ctx.fillRect(sx, sy + 40, sw, sh)
}
// make light columns wider to illuminate surrounding blocks
const extra = Math.floor(B / 2)
const reflectedLight = ambientLight.slice(0, -1) + ', 50%)'
ctx.fillStyle = reflectedLight
for (let col = 0; col < W / B; col++) {
const level = (barrier[col] - y.value) * B
const sw = B
const sh = level
const sx = col * sw
const sy = 0
ctx.fillRect(sx - extra, sy - extra, sw + extra * 2, sh + extra * 2)
}
// TODO: draw light for candles and torches
}
return function update() {
ctx.fillStyle = getAmbientLightColor()
// first, throw the world in complete darkness
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, W, H)
// TODO: switch from drawing shadows to drawing lights
drawShadows()
// second, find and bring light into the world
drawLights()
// finally, draw the players light
// with a size multiplicator which might be later used to
// simulate greater illumination with candles or torches
drawPlayerLight(1)
}
}

@ -1,7 +1,17 @@
import { computed, reactive } from 'vue'
import { RECIPROCAL, STAGE_WIDTH, STAGE_HEIGHT } from '../level/def'
import type { Item, ItemQuality } from '../level/items'
const player: Player = reactive({
export interface InventoryItem extends Item {
amount: number
quality: ItemQuality | null
}
export interface Player extends Moveable {
inventory: InventoryItem[]
}
const player = reactive<Player>({
x: Math.round((STAGE_WIDTH + 2) / 2),
y: Math.round((STAGE_HEIGHT + 2) / 2),
lastDir: 0,
@ -10,10 +20,40 @@ const player: Player = reactive({
inventory: [], // not yet in use
})
const pocket = (newItem: Item) => {
const existing = player.inventory.find(item => item.name === newItem.name)
if (existing) {
existing.amount += 1
return existing.amount
}
player.inventory.push({
quality: null,
amount: 1,
...newItem
})
return 1
}
const unpocket = (oldItem: Item) => {
const existingIndex = player.inventory.findIndex(item => item.name === oldItem.name)
if (existingIndex < 0) return 0
const existing = player.inventory[existingIndex]
if (existing.amount > 1) {
existing.amount -= 1
return existing.amount
}
player.inventory.splice(existingIndex, 1)
return 0
}
export default function usePlayer() {
const dx = computed(() => player.vx * RECIPROCAL)
const dy = computed(() => player.vy * RECIPROCAL)
const direction = computed(() => (player.lastDir < 0 ? 'left' : 'right'))
return { player, direction, dx, dy }
return { player, direction, dx, dy, pocket, unpocket }
}

12
src/vite-env.d.ts vendored

@ -9,18 +9,6 @@ declare global {
vy: number // velocity on the y-axis
}
type InventoryItem = {
name: string
type: string
icon: string
amount: number
quality?: 'bronze' | 'iron' | 'silver' | 'gold' | 'diamond'
}
interface Player extends Moveable {
inventory: InventoryItem[]
}
interface Npc extends Moveable {
hostile: boolean
inventory: InventoryItem[]

Loading…
Cancel
Save