Basic sitemode

This commit is contained in:
Pyrex 2023-09-22 21:08:03 -07:00
parent 76e92a2a50
commit e3e43f0223
17 changed files with 568 additions and 15 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,72 @@
import 'package:collection/collection.dart';
class Edge<T> {
final double cost;
final T destination;
Edge(this.cost, this.destination);
}
class Result<T> {
final double cost;
final T? predecessor;
final T item;
Result(this.cost, this.predecessor, this.item);
}
Iterable<Result<T>> dijkstra<T>(
T source, Iterable<Edge<T>> Function(T) neighbors) sync* {
var queue = PriorityQueue<Result<T>>((i0, i1) => i0.cost.compareTo(i1.cost));
Set<T> 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<T>? dijkstraPath<T>(
T source, T destination, Iterable<Edge<T>> Function(T) neighbors,
{double? maxCost}) {
if (source == destination) {
return [];
}
Map<T, T?> 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);
}
}
}

View File

@ -17,6 +17,13 @@ class Size {
String toString() { String toString() {
return "$dx x $dy"; 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 { class Offset {
@ -29,6 +36,13 @@ class Offset {
String toString() { String toString() {
return "@($x, $y)"; 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 { class Rect {
@ -63,4 +77,15 @@ class Rect {
String toString() { String toString() {
return "@($x0, $y0) $size"; 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;
} }

View File

@ -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<geo.Offset> 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();
}

View File

@ -2,10 +2,25 @@ import 'package:flutter/material.dart';
class Palette { class Palette {
static const defaultBg = Color(0xFF272D1B); static const defaultBg = Color(0xFF272D1B);
static const uiBg = Color(0xFF847A4B); static const uiBg = Color(0xFF232308);
static const defaultFg = Color(0xFFEEE9D1); static const defaultFg = Color(0xFFEEE9D1);
static const demoPlayer = Color(0xFFFEFEF2); static const demoPlayer = Color(0xFFFEFEF2);
static const demoDoor = Color(0xFF847A4B); static const demoDoor = Color(0xFF847A4B);
static const demoExit = 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);
} }

View File

@ -260,6 +260,7 @@ final List<String> _fromCp437 = [
"\u00a0" "\u00a0"
]; ];
final Map<String, Cp437> _toCp437 = {}; final Map<String, Cp437> _toCp437 = {};
final Map<int, Cp437> _toCp437I = {};
void _init() { void _init() {
if (initialized) { if (initialized) {
@ -268,6 +269,7 @@ void _init() {
for (final (i, c) in _fromCp437.indexed) { for (final (i, c) in _fromCp437.indexed) {
_toCp437[c] = i; _toCp437[c] = i;
_toCp437I[c.runes.first] = i;
} }
initialized = true; initialized = true;
@ -291,6 +293,7 @@ Cp437 toCp437Char(String c) {
} }
String fromCp437String(List<Cp437> s) { String fromCp437String(List<Cp437> s) {
_init();
var out = ""; var out = "";
for (final c in s) { for (final c in s) {
out += fromCp437Char(c); out += fromCp437Char(c);
@ -299,9 +302,10 @@ String fromCp437String(List<Cp437> s) {
} }
List<Cp437> toCp437String(String s) { List<Cp437> toCp437String(String s) {
_init();
List<Cp437> out = []; List<Cp437> out = [];
for (final c in s.runes) { for (final c in s.runes) {
out.add(c); out.add(_toCp437I[c] ?? toCp437Char("?"));
} }
return out; return out;
} }

View File

@ -53,7 +53,6 @@ Future<Level> getLevel() async {
getVaultsIfAvailable("assets/images/vaults/house1.png"); getVaultsIfAvailable("assets/images/vaults/house1.png");
if (maybeVaults != null) { if (maybeVaults != null) {
skreek("wasn't null!");
vaults = maybeVaults; vaults = maybeVaults;
break; break;
} }

View File

@ -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);
}
}
}

View File

@ -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<geo.Offset>(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<Edge<geo.Offset>> _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);
}
}
}

View File

@ -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<void> 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);
}
}

View File

@ -1,23 +1,65 @@
import 'package:dartterm/algorithms/dijkstra.dart';
import 'package:dartterm/algorithms/geometry.dart' as geo; 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/terminal.dart';
import 'package:dartterm/world/level.dart'; import 'package:dartterm/world/level.dart';
import 'dart:math' as math;
part 'camera.dart';
part 'player.dart';
part 'fov.dart';
Future<void> sitemode(Level level) async { Future<void> sitemode(Level level) async {
await _SiteMode(level).start(); await SiteMode(level).start();
} }
class _SiteMode { class SiteMode {
Level level; Level level;
late geo.Offset position; late geo.Offset playerPosition;
bool playerTookAutomatedAction = false;
List<geo.Offset> playerIntendedPath = [];
_SiteMode(this.level) { late geo.Offset camera;
position = level.spawn;
late Set<geo.Offset> fovVisible;
late Set<geo.Offset> fovMovable;
Set<geo.Offset> fovMemory = {};
SiteMode(this.level) {
playerPosition = level.spawn;
init();
maintain();
}
void init() {
cameraInit();
}
void maintain() {
playerMaintain();
fovMaintain();
cameraMaintain();
}
void draw() {
clear();
cameraDraw();
} }
Future<void> start() async { Future<void> start() async {
while (true) { while (true) {
at(0, 0).puts("Site mode!"); maintain();
await zzz(0.1); draw();
// take automated actions, otherwise receive input
if (playerTookAutomatedAction) {
await zzz(0.1);
} else {
await waitMenu();
}
} }
} }
} }

View File

@ -28,7 +28,6 @@ class Vaults {
} }
static Vault loadVault(Region r, Bitmap<VaultTile?> b) { static Vault loadVault(Region r, Bitmap<VaultTile?> b) {
skreek("Loading vault: $r");
var tiles = [ var tiles = [
for (var y = r.rect.y0; y < r.rect.y1; y++) for (var y = r.rect.y0; y < r.rect.y1; y++)
for (var x = r.rect.x0; x < r.rect.x1; x++) for (var x = r.rect.x0; x < r.rect.x1; x++)

View File

@ -17,6 +17,7 @@ class App extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'DARTTERM', title: 'DARTTERM',
theme: ThemeData( theme: ThemeData(
useMaterial3: true, scaffoldBackgroundColor: Palette.defaultBg), useMaterial3: true, scaffoldBackgroundColor: Palette.defaultBg),

View File

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
@ -72,7 +71,6 @@ class Terminal {
} }
void _notifyInput(Input i) { void _notifyInput(Input i) {
skreek("Input: $i $_lastSeenMouse");
_inputSink.add(i); _inputSink.add(i);
} }

View File

@ -18,12 +18,20 @@ class Cursor {
t._tiles = [for (var i = 0; i < nTiles; i += 1) Tile()]; 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 dx = 0; dx < font.cellWidth; dx += 1) {
for (var dy = 0; dy < font.cellHeight; dy += 1) { for (var dy = 0; dy < font.cellHeight; dy += 1) {
var i = t._fromXY(x + dx, y + dy); var i = t._fromXY(x + dx, y + dy);
if (i != null) { 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) = ( final (sourceCx, sourceCy) = (
(c % font.nCellsW) * font.cellWidth + dx, (c % font.nCellsW) * font.cellWidth + dx,
(c ~/ font.nCellsW) * font.cellHeight + dy (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) { void puts(String s) {
var startX = x; var startX = x;
for (final c in toCp437String(s)) { for (final c in toCp437String(s)) {

View File

@ -34,7 +34,7 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687

View File

@ -28,6 +28,7 @@ environment:
# the latest version available on pub.dev. To see which dependencies have newer # the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`. # versions available, run `flutter pub outdated`.
dependencies: dependencies:
collection: ^1.17.2
flutter: flutter:
sdk: flutter sdk: flutter