diff --git a/assets/images/fonts/font_big.png b/assets/images/fonts/font_big.png index 37072e9..5b749f9 100644 Binary files a/assets/images/fonts/font_big.png and b/assets/images/fonts/font_big.png differ diff --git a/lib/algorithms/dijkstra.dart b/lib/algorithms/dijkstra.dart new file mode 100644 index 0000000..3d8768f --- /dev/null +++ b/lib/algorithms/dijkstra.dart @@ -0,0 +1,72 @@ +import 'package:collection/collection.dart'; + +class Edge { + final double cost; + final T destination; + + Edge(this.cost, this.destination); +} + +class Result { + final double cost; + final T? predecessor; + final T item; + + Result(this.cost, this.predecessor, this.item); +} + +Iterable> dijkstra( + T source, Iterable> Function(T) neighbors) sync* { + var queue = PriorityQueue>((i0, i1) => i0.cost.compareTo(i1.cost)); + + Set seen = {source}; + queue.add(Result(0.0, null, source)); + + while (queue.isNotEmpty) { + var u = queue.removeFirst(); + yield u; + + for (var v in neighbors(u.item)) { + if (seen.contains(v.destination)) { + continue; + } + + seen.add(v.destination); + queue.add(Result(u.cost + v.cost, u.item, v.destination)); + } + } +} + +List? dijkstraPath( + T source, T destination, Iterable> Function(T) neighbors, + {double? maxCost}) { + if (source == destination) { + return []; + } + Map predecessor = {}; + + for (var r in dijkstra(source, neighbors)) { + predecessor[r.item] = r.predecessor; + if (maxCost != null && r.cost >= maxCost) { + return null; + } + if (r.item == destination) { + break; + } + } + + var revPath = [destination]; + while (true) { + var pred = predecessor[revPath.last]; + + if (pred == source) { + revPath.reverseRange(0, revPath.length); + return revPath; + } else if (pred == null) { + throw Exception( + "predecessor should not be null -- that would mean we missed source"); + } else { + revPath.add(pred); + } + } +} diff --git a/lib/algorithms/geometry.dart b/lib/algorithms/geometry.dart index e1f705a..9e8b656 100644 --- a/lib/algorithms/geometry.dart +++ b/lib/algorithms/geometry.dart @@ -17,6 +17,13 @@ class Size { String toString() { return "$dx x $dy"; } + + @override + bool operator ==(Object other) => + other is Size && other.dx == dx && other.dy == dy; + + @override + int get hashCode => (dx, dy).hashCode; } class Offset { @@ -29,6 +36,13 @@ class Offset { String toString() { return "@($x, $y)"; } + + @override + bool operator ==(Object other) => + other is Offset && other.x == x && other.y == y; + + @override + int get hashCode => (x, y).hashCode; } class Rect { @@ -63,4 +77,15 @@ class Rect { String toString() { return "@($x0, $y0) $size"; } + + @override + bool operator ==(Object other) => + other is Rect && + other.x0 == x0 && + other.y0 == y0 && + other.dx == dx && + other.dy == dy; + + @override + int get hashCode => (x0, y0, dx, dy).hashCode; } diff --git a/lib/algorithms/shadowcasting.dart b/lib/algorithms/shadowcasting.dart new file mode 100644 index 0000000..f0bf0e1 --- /dev/null +++ b/lib/algorithms/shadowcasting.dart @@ -0,0 +1,141 @@ +// Port of https://www.albertford.com/shadowcasting/ +import 'package:dartterm/algorithms/geometry.dart' as geo; + +void shadowcast(geo.Offset origin, bool Function(geo.Offset) isBlocking, + Function(geo.Offset) markVisible) { + markVisible(origin); + + for (var i = 0; i < 4; i++) { + var quadrant = Quadrant(i, origin.x, origin.y); + + void reveal(geo.Offset tile) { + markVisible(quadrant.transform(tile)); + } + + bool isWall(geo.Offset? tile) { + if (tile == null) { + return false; + } + + return isBlocking(quadrant.transform(tile)); + } + + bool isFloor(geo.Offset? tile) { + if (tile == null) { + return false; + } + + return !isBlocking(quadrant.transform(tile)); + } + + void scan(Row row) { + geo.Offset? prevTile; + for (var tile in row.tiles()) { + if (isWall(tile) || isSymmetric(row, tile)) { + reveal(tile); + } + if (isWall(prevTile) && isFloor(tile)) { + row.startSlope = slope(tile); + } + if (isFloor(prevTile) && isWall(tile)) { + Row nextRow = row.next(); + nextRow.endSlope = slope(tile); + scan(nextRow); + } + prevTile = tile; + } + + if (isFloor(prevTile)) { + scan(row.next()); + } + } + + Row firstRow = Row(1, Fraction(-1, 1), Fraction(1, 1)); + scan(firstRow); + } +} + +class Quadrant { + static const int north = 0; + static const int east = 1; + static const int south = 2; + static const int west = 3; + + final int cardinal; + final int ox, oy; + + Quadrant(this.cardinal, this.ox, this.oy); + + geo.Offset transform(geo.Offset tile) { + var geo.Offset(x: row, y: col) = tile; + + switch (cardinal) { + case north: + return geo.Offset(ox + col, oy - row); + case south: + return geo.Offset(ox + col, oy + row); + case east: + return geo.Offset(ox + row, oy + col); + case west: + return geo.Offset(ox - row, oy + col); + default: + throw Exception("Quadrant must be initialized with a real cardinal"); + } + } +} + +class Row { + int depth; + Fraction startSlope; + Fraction endSlope; + + Row(this.depth, this.startSlope, this.endSlope); + + Iterable tiles() sync* { + var minCol = roundTiesUp(startSlope.scale(depth)); + var maxCol = roundTiesDown(endSlope.scale(depth)); + for (int col = minCol; col <= maxCol; col++) { + yield geo.Offset(depth, col); + } + } + + Row next() { + return Row(depth + 1, startSlope, endSlope); + } +} + +class Fraction { + final int numerator; + final int denominator; + + Fraction(this.numerator, this.denominator); + + Fraction scale(int n) { + return Fraction(numerator * n, denominator); + } + + // We're often comparing this to an int or a double, so it's OK + // to have precision loss _so long as we do all divides after all multiplies_ + double toDouble() { + return numerator.toDouble() / denominator.toDouble(); + } +} + +Fraction slope(geo.Offset tile) { + var geo.Offset(x: rowDepth, y: col) = tile; + return Fraction(2 * col - 1, 2 * rowDepth); +} + +bool isSymmetric(Row row, geo.Offset tile) { + var geo.Offset(x: rowDepth, y: col) = tile; + return (col >= row.startSlope.scale(rowDepth).toDouble() && + col <= (row.endSlope.scale(row.depth)).toDouble()); +} + +int roundTiesUp(Fraction n) { + return (n.toDouble() + 0.5).floor(); +} + +int roundTiesDown(Fraction n) { + return (n.toDouble() - 0.5).ceil(); +} diff --git a/lib/colors.dart b/lib/colors.dart index 17610a1..f8897b1 100644 --- a/lib/colors.dart +++ b/lib/colors.dart @@ -2,10 +2,25 @@ import 'package:flutter/material.dart'; class Palette { static const defaultBg = Color(0xFF272D1B); - static const uiBg = Color(0xFF847A4B); + static const uiBg = Color(0xFF232308); static const defaultFg = Color(0xFFEEE9D1); static const demoPlayer = Color(0xFFFEFEF2); static const demoDoor = Color(0xFF847A4B); static const demoExit = Color(0xFF847A4B); + + static const sitemodePlayer = demoPlayer; + + static const sitemodeSeenDoor = Color(0xFFFEFD4B); + static const sitemodeUnseenDoor = demoExit; + + static const sitemodeSeenWall = defaultFg; + static const sitemodeUnseenWall = demoExit; + + static const sitemodeSeenExit = defaultFg; + static const sitemodeUnseenExit = demoExit; + + static const sitemodeSeenFloor = uiBg; + + static const demoFloorHighlight = Color(0xFF863B6F); } diff --git a/lib/cp437.dart b/lib/cp437.dart index cb77f5f..8b9426d 100644 --- a/lib/cp437.dart +++ b/lib/cp437.dart @@ -260,6 +260,7 @@ final List _fromCp437 = [ "\u00a0" ]; final Map _toCp437 = {}; +final Map _toCp437I = {}; void _init() { if (initialized) { @@ -268,6 +269,7 @@ void _init() { for (final (i, c) in _fromCp437.indexed) { _toCp437[c] = i; + _toCp437I[c.runes.first] = i; } initialized = true; @@ -291,6 +293,7 @@ Cp437 toCp437Char(String c) { } String fromCp437String(List s) { + _init(); var out = ""; for (final c in s) { out += fromCp437Char(c); @@ -299,9 +302,10 @@ String fromCp437String(List s) { } List toCp437String(String s) { + _init(); List out = []; for (final c in s.runes) { - out.add(c); + out.add(_toCp437I[c] ?? toCp437Char("?")); } return out; } diff --git a/lib/game/game.dart b/lib/game/game.dart index bd8ed82..fe9873b 100644 --- a/lib/game/game.dart +++ b/lib/game/game.dart @@ -53,7 +53,6 @@ Future getLevel() async { getVaultsIfAvailable("assets/images/vaults/house1.png"); if (maybeVaults != null) { - skreek("wasn't null!"); vaults = maybeVaults; break; } diff --git a/lib/game/sitemode/camera.dart b/lib/game/sitemode/camera.dart new file mode 100644 index 0000000..680b2b5 --- /dev/null +++ b/lib/game/sitemode/camera.dart @@ -0,0 +1,127 @@ +part of 'sitemode.dart'; + +// We render each thing as a 2x2 block. +// We want the player's cell to be +// _actually centered_, and the terminal is an even number of cells across +// So, shifting the camera an extra cell to the north and then +// drawing one extra tile offscreen? That should accomplish it! +const cameraViewWidth = Terminal.width ~/ 2 + 1; +const cameraViewHeight = Terminal.height ~/ 2 + 1; +const cameraMargin = + 4; // how far the camera is from the ideal position before it pans +const cameraTileOffset = geo.Offset(-1, -1); + +extension CameraParts on SiteMode { + void cameraInit() { + camera = _cameraIdeal(); + } + + void cameraMaintain() { + var ideal = _cameraIdeal(); + while (camera.x < ideal.x - cameraMargin) { + camera = geo.Offset(camera.x + 1, camera.y); + } + while (camera.x > ideal.x + cameraMargin) { + camera = geo.Offset(camera.x - 1, camera.y); + } + while (camera.y < ideal.y - cameraMargin) { + camera = geo.Offset(camera.x, camera.y + 1); + } + while (camera.y > ideal.y + cameraMargin) { + camera = geo.Offset(camera.x, camera.y - 1); + } + } + + geo.Offset _cameraIdeal() { + return geo.Offset(playerPosition.x - cameraViewWidth ~/ 2 - 1, + playerPosition.y - cameraViewHeight ~/ 2 - 1); + } + + void cameraDraw() { + // Draw the world! + // Work in columns, top to bottom, which should facilitate our fake 3D effects + for (var cx = 0; cx < cameraViewWidth; cx++) { + for (var cy = 0; cy < cameraViewHeight; cy++) { + var tx = cameraTileOffset.x + cx * 2; + var ty = cameraTileOffset.y + cy * 2; + cameraDrawFloor(tx, ty, camera.x + cx, camera.y + cy); + } + } + for (var cx = 0; cx < cameraViewWidth; cx++) { + for (var cy = 0; cy < cameraViewHeight; cy++) { + var tx = cameraTileOffset.x + cx * 2; + var ty = cameraTileOffset.y + cy * 2; + cameraDrawCell(tx, ty, camera.x + cx, camera.y + cy); + } + } + } + + void cameraDrawFloor(int tx, int ty, int cx, int cy) { + var cxy = geo.Offset(cx, cy); + if (!fovVisible.contains(cxy)) { + return; + } + + var tile = level.tiles.get(cx, cy); + at(tx, ty).bg(Palette.sitemodeSeenFloor).puts(" "); + } + + void cameraDrawCell(int tx, int ty, int cx, int cy) { + var cursorAbove = at(tx, ty); + var cursorBelow = at(tx, ty + 2); + + LevelTile? tile; + // TODO: Fade tiles when loaded from memory + // TODO: Darken live floors + bool seenLive; + + var cxy = geo.Offset(cx, cy); + if (fovVisible.contains(cxy)) { + tile = level.tiles.get(cx, cy); + seenLive = true; + } else if (fovMemory.contains(cxy)) { + tile = level.tiles.get(cx, cy); + seenLive = false; + } else { + return; + } + + if (fovMovable.contains(geo.Offset(cx, cy))) { + cursorAbove + .highlight() + .act(Act(label: "Move", callback: () => playerMoveTo(cx, cy))); + } + + var colorWall = Palette.sitemodeSeenWall; + var colorWallInner = Palette.sitemodeSeenWall; + var colorExit = Palette.sitemodeSeenExit; + var colorDoor = Palette.sitemodeSeenDoor; + if (cy >= playerPosition.y) { + colorWallInner = Palette.sitemodeUnseenWall; // we wouldn't see it anyway + } + if (!seenLive) { + colorWallInner = colorWall = Palette.sitemodeUnseenWall; + colorExit = Palette.sitemodeUnseenExit; + colorDoor = Palette.sitemodeUnseenDoor; + } + + switch (tile) { + case null: + case LevelTile.floor: + case LevelTile.openDoor: + cursorAbove.touch(2); + case LevelTile.exit: + cursorAbove.big().fg(colorExit).puts("X"); + case LevelTile.wall: + cursorAbove.fg(colorWall).puts("██"); + cursorBelow.fg(colorWallInner).puts("░░"); + case LevelTile.closedDoor: + cursorAbove.big().fg(colorDoor).puts("+"); + } + + var cursorContent = at(tx, ty); + if (geo.Offset(cx, cy) == playerPosition) { + cursorContent.fg(Palette.sitemodePlayer).big().putc(0xff); + } + } +} diff --git a/lib/game/sitemode/fov.dart b/lib/game/sitemode/fov.dart new file mode 100644 index 0000000..727a815 --- /dev/null +++ b/lib/game/sitemode/fov.dart @@ -0,0 +1,81 @@ +part of 'sitemode.dart'; + +const double fovMaxMovementCost = 10.0; +const double fovMaxMemoryDistance = 80.0; + +extension FOVParts on SiteMode { + void fovMaintain() { + fovVisible = {}; + shadowcast(playerPosition, _fovIsBlocking, (xy) => fovVisible.add(xy)); + + var oldFovMemory = fovMemory; + fovMemory = {}; + oldFovMemory.addAll(fovVisible); + for (var xy in oldFovMemory) { + if (_fovMemorablyClose(xy)) { + fovMemory.add(xy); + } + } + + fovMovable = {}; + for (var r in dijkstra(playerPosition, _fovDijkstraNeighbors)) { + if (r.cost > fovMaxMovementCost) { + break; + } + fovMovable.add(r.item); + } + } + + bool _fovMemorablyClose(geo.Offset other) { + var dx = (other.x - playerPosition.x).toDouble(); + var dy = (other.y - playerPosition.y).toDouble(); + return math.sqrt(dx * dx + dy * dy) < fovMaxMemoryDistance; + } + + bool _fovKnown(geo.Offset other) { + return fovVisible.contains(other) || fovMemory.contains(other); + } + + bool _fovIsBlocking(geo.Offset xy) { + switch (level.tiles.get(xy.x, xy.y)) { + case null: + return true; + case LevelTile.exit: + return true; + case LevelTile.openDoor: + case LevelTile.floor: + return false; + case LevelTile.wall: + return true; + case LevelTile.closedDoor: + return true; + } + } + + Iterable> _fovDijkstraNeighbors(geo.Offset xy) sync* { + var tile = level.tiles.get(xy.x, xy.y); + if (tile == LevelTile.exit || tile == LevelTile.closedDoor) { + // can't go anywhere after this + return; + } + + for (var (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1)]) { + var xy2 = geo.Offset(xy.x + dx, xy.y + dy); + + // Only return visible or remembered tiles + if (!_fovKnown(xy2)) { + continue; + } + + var tile = level.tiles.get(xy2.x, xy2.y); + if (tile == LevelTile.wall) { + continue; + } + // for now, because exiting isn't implemented + if (tile == LevelTile.exit) { + continue; + } + yield Edge(1.0, xy2); + } + } +} diff --git a/lib/game/sitemode/player.dart b/lib/game/sitemode/player.dart new file mode 100644 index 0000000..21424e3 --- /dev/null +++ b/lib/game/sitemode/player.dart @@ -0,0 +1,33 @@ +part of 'sitemode.dart'; + +extension PlayerParts on SiteMode { + void playerMaintain() { + playerTookAutomatedAction = false; + var geo.Offset(:x, :y) = playerPosition; + + var tile = level.tiles.get(x, y); + if (tile == LevelTile.closedDoor) { + level.tiles.set(x, y, LevelTile.openDoor); + } + + if (playerIntendedPath.isNotEmpty) { + var nextPosition = playerIntendedPath.removeAt(0); + playerPosition = nextPosition; + playerTookAutomatedAction = true; + } + } + + // support the UI command here + Future playerMoveTo(int cx, int cy) async { + var ipath = dijkstraPath( + geo.Offset(playerPosition.x, playerPosition.y), + geo.Offset(cx, cy), + _fovDijkstraNeighbors, + ); + + if (ipath == null) { + return; // don't move, I guess + } + playerIntendedPath.addAll(ipath); + } +} diff --git a/lib/game/sitemode/sitemode.dart b/lib/game/sitemode/sitemode.dart index 06a5b35..0176d47 100644 --- a/lib/game/sitemode/sitemode.dart +++ b/lib/game/sitemode/sitemode.dart @@ -1,23 +1,65 @@ +import 'package:dartterm/algorithms/dijkstra.dart'; import 'package:dartterm/algorithms/geometry.dart' as geo; +import 'package:dartterm/algorithms/shadowcasting.dart'; +import 'package:dartterm/colors.dart'; +import 'package:dartterm/skreek.dart'; import 'package:dartterm/terminal.dart'; import 'package:dartterm/world/level.dart'; +import 'dart:math' as math; + +part 'camera.dart'; +part 'player.dart'; +part 'fov.dart'; Future sitemode(Level level) async { - await _SiteMode(level).start(); + await SiteMode(level).start(); } -class _SiteMode { +class SiteMode { Level level; - late geo.Offset position; + late geo.Offset playerPosition; + bool playerTookAutomatedAction = false; + List playerIntendedPath = []; - _SiteMode(this.level) { - position = level.spawn; + late geo.Offset camera; + + late Set fovVisible; + late Set fovMovable; + Set fovMemory = {}; + + SiteMode(this.level) { + playerPosition = level.spawn; + + init(); + maintain(); + } + + void init() { + cameraInit(); + } + + void maintain() { + playerMaintain(); + fovMaintain(); + cameraMaintain(); + } + + void draw() { + clear(); + cameraDraw(); } Future start() async { while (true) { - at(0, 0).puts("Site mode!"); - await zzz(0.1); + maintain(); + draw(); + + // take automated actions, otherwise receive input + if (playerTookAutomatedAction) { + await zzz(0.1); + } else { + await waitMenu(); + } } } } diff --git a/lib/gen/vaults.dart b/lib/gen/vaults.dart index b992cfd..a4a4986 100644 --- a/lib/gen/vaults.dart +++ b/lib/gen/vaults.dart @@ -28,7 +28,6 @@ class Vaults { } static Vault loadVault(Region r, Bitmap b) { - skreek("Loading vault: $r"); var tiles = [ for (var y = r.rect.y0; y < r.rect.y1; y++) for (var x = r.rect.x0; x < r.rect.x1; x++) diff --git a/lib/main.dart b/lib/main.dart index 365147a..a726fcd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + debugShowCheckedModeBanner: false, title: 'DARTTERM', theme: ThemeData( useMaterial3: true, scaffoldBackgroundColor: Palette.defaultBg), diff --git a/lib/terminal.dart b/lib/terminal.dart index 4ce09bf..da65be8 100644 --- a/lib/terminal.dart +++ b/lib/terminal.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:developer'; import 'dart:math' as math; import 'dart:ui' as ui; @@ -72,7 +71,6 @@ class Terminal { } void _notifyInput(Input i) { - skreek("Input: $i $_lastSeenMouse"); _inputSink.add(i); } diff --git a/lib/terminal/cursor.dart b/lib/terminal/cursor.dart index 7d34fdc..5360a5a 100644 --- a/lib/terminal/cursor.dart +++ b/lib/terminal/cursor.dart @@ -18,12 +18,20 @@ class Cursor { t._tiles = [for (var i = 0; i < nTiles; i += 1) Tile()]; } - void putc(int c) { + void putc(int? c) { for (var dx = 0; dx < font.cellWidth; dx += 1) { for (var dy = 0; dy < font.cellHeight; dy += 1) { var i = t._fromXY(x + dx, y + dy); if (i != null) { + // you can pass null and in that case a character-sized block of + // screen is automatically UI-affected, but not drawn + if (c == null) { + t._tiles[i].highlightGroup = highlightGroup; + t._tiles[i].actions.addAll(_acts); + continue; + } + final (sourceCx, sourceCy) = ( (c % font.nCellsW) * font.cellWidth + dx, (c ~/ font.nCellsW) * font.cellHeight + dy @@ -48,6 +56,13 @@ class Cursor { } } + void touch(int n) { + for (var i = 0; i < n; i++) { + putc(null); + x += font.cellWidth; + } + } + void puts(String s) { var startX = x; for (final c in toCp437String(s)) { diff --git a/pubspec.lock b/pubspec.lock index b898505..4d14e43 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,7 +34,7 @@ packages: source: hosted version: "1.1.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 diff --git a/pubspec.yaml b/pubspec.yaml index 5fdb915..755f714 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: + collection: ^1.17.2 flutter: sdk: flutter