feat(packages): ludic.photo - a camera's photographs: what the lens sees (the frustum and a line of sight over the ground and the trunks), a grade out of a hundred on size, framing, light and the moment weighted by how much subject there is, the frame's best subject and its tags, the roll with its order and pages, a buyer's price on the grade's curve, the best of each subject; the lens, the light and the market through ports; kept, full, deleted and sold as facts; the roll in its own save section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 08:58:00 +03:00
parent 473552cc81
commit 24a35f1174
17 changed files with 975 additions and 0 deletions

View file

@ -24,6 +24,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |
| [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides |
| [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse |
| [ludic.photo](ludic.photo/README.md) | a camera's photographs: what is in the frame, a grade on size, framing, light and the moment, the roll, its worth, the best of each subject |
| [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only |
| [ludic.settings](ludic.settings/README.md) | a game's settings as data: one store, a fact per change, ranges, a safe set |
| [ludic.shop](ludic.shop/README.md) | vendors: prices by standing and weekly demand, stock, buying and selling |

View file

@ -0,0 +1,73 @@
# ludic.photo
A camera's photographs: what is in the frame and can be seen, a grade out of a hundred for its best
subject, the roll it goes onto with its tags and order, what each frame is worth to a buyer, and the
best frame yet of every subject. Uses [`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
import "ludic.photo"
```
The package never reads pixels and never draws. The game offers the subjects and tags of a frame,
reads its picture back and writes the file itself, then keeps it with `photo_keep(file, day, hour)`;
what that means in the game - the life list, the jobs, the words - is the `PHOTO_KEPT` fact.
## The grade
Out of a hundred, for the frame's best subject:
| | |
| --- | --- |
| size (40) | the subject's height as a share of the picture: under 4% a record shot, a quarter to two thirds pays, past 85% cropped |
| framing (20) | how near a thirds point it sits; a subject cut by an edge keeps a third of that (a tripod adds 5) |
| light (20) | the hour after dawn and before dusk best, flat noon middling, the moon at night; the weather's `dim` and `bonus`; backlit costs 7 |
| the moment (20) | what it is doing, as the game scores it (head up and still unaware is worth most) |
Framing, light and the moment are weighted by how much subject there is, so a deer three pixels wide
in perfect light is not a photograph. `PhotoRules.behaviour` halves size and doubles the moment.
**A tag is a tag at any grade**: the grade is what a frame is worth, never whether it happened.
`photo_why(i)` is the worst component (`PHOTO_WHY_SIZE`, `_FRAME`, `_LIGHT`, `_MOMENT`, or `_FINE`),
for the game to word.
## The ports
```ludic
export port PhotoLens {
x, y, z, fx, fy, fz, fov, # the camera
project(x, y, z) -> bool, sx(), sy(), # into the picture, 0..1
visible(x, y, z, r) -> bool, # the frustum
ground(x, z), trunk(x, z, r) -> bool, # what a line of sight walks
range(), steady() -> bool # the lens in the pack
}
export port PhotoLight { hour, night, moon, dim, bonus, sun_x, sun_z }
export port PhotoRules { behaviour, subject_value(sp), legend_value(sp), mult }
```
Every member has a default: a lens at the origin looking down -z on flat open ground at noon.
## API
| | |
| --- | --- |
| `photo_frame_begin()`, `photo_frame_subject(sp, name, x, y, z, h, legend, moment) -> bool`, `photo_frame_tag(text)`, `photo_frame_sign(n)`, `photo_frame_stars()` | composing: a subject the lens sees is tagged (a kind once, every legend) and scored |
| `photo_pending_tags/mask/legend/grade/why/sign/stars()`, `photo_comp(k)`, `photo_comp_have()` | the frame being composed, for a viewfinder and its meter |
| `photo_in_view(x, y, z, far)`, `photo_score(x, y, z, h, moment)`, `photo_score_size/frame/light` | the parts, for a game's own questions |
| `photo_keep(file, day, hour) -> int`, `photo_add(...)`, `photo_remove(i)`, `photo_cover_set(i)` | the roll: kept (or -1 and `PHOTO_FULL`), added as saved, forgotten (`PHOTO_DELETED` names the file) |
| `photo_count()`, `photo_max()`, `photo_full()`, `photo_day/hour/mask/legend_mask/sold/grade/why/tags/file/graded(i)`, `photo_cover()` | the roll's frames |
| `photo_species_shot()`, `photo_legend_shot()`, `photo_best(sp)`, `photo_record_set`, `photo_best_set` | what was ever photographed (sold or deleted still counts) and the best grade of each subject |
| `photo_band(g)` (0 a record shot .. 4 one for the wall), `photo_grade_mult(g)` (0.35 .. 4) | the grade's bands and its price curve |
| `photo_value(i)`, `photo_worth()`, `photo_sell_all()` | a buyer pays for a subject's first frame and every legend, on the curve; `PHOTO_SOLD` per frame |
| `photo_sort()`, `photo_sort_next()` (`PHOTO_NEWEST`, `_BEST`, `_BY_SUBJECT`), `photo_order()`, `photo_page_list()`, `photo_pages()`, `photo_page_now()`, `photo_page_step(d)` | the roll in order, eight to a page |
| `photo_slug(tags)` | a file name from the tags |
| `photo_facts() -> Queue<PhotoFact>` | `{ what, index, mask, legend, firsts, grade, sign, stars, file, tags, v }`: `PHOTO_KEPT`, `_FULL`, `_DELETED`, `_SOLD` |
| `photo_config(max)`, `photo_reset()`, `photo_system() -> System` | `"photo"`: the frames, the record, the cover and the order in its own section, by key |
## Tests
```bash
ludic test packages/ludic.photo
```
A lens over flat ground with a ridge and a trunk: what is in view, the four parts of the grade, the
frame's best subject and its tags, keeping and the full card, value and selling, forgetting a frame,
the roll's order and pages, file names and the save.

View file

@ -0,0 +1,37 @@
# frame.ludic - what the camera sees right now: the game offers each subject and tag, and the frame
# takes the best subject's grade - which is what a photographer would say about it
export function photo_frame_begin() -> void {
pht_pending_tags = ""
pht_pending_mask = 0
pht_pending_legend = 0
pht_pending_grade = 0
pht_pending_why = PHOTO_WHY_NONE
pht_pending_sign = -1
pht_pending_stars = false
}
# A subject `sp` (0..31) named `name`, h metres tall, standing at (x, y, z) and doing something worth
# `moment`. True when the lens sees it within its range: it is tagged (once a kind, every legend) and
# scored, and the best score is the frame's.
export function photo_frame_subject(sp: int, name: string, x: float, y: float, z: float, h: float, legend: bool, moment: int) -> bool {
if not photo_in_view(x, y + 0.8, z, PhotoLens.range()) { return false }
if (pht_pending_mask & (1 << sp)) == 0 or legend { photo_frame_tag(name) }
pht_pending_mask = pht_pending_mask | (1 << sp)
if legend { pht_pending_legend = pht_pending_legend | (1 << sp) }
let g = photo_score(x, y, z, h, moment)
if g > pht_pending_grade {
pht_pending_grade = g
pht_pending_why = photo_why_of_comp()
}
return true
}
# anything else in the picture worth naming: a landmark, the water, a sign
export function photo_frame_tag(text: string) -> void {
if len(text) == 0 { return }
if len(pht_pending_tags) > 0 { pht_pending_tags = pht_pending_tags + ", " }
pht_pending_tags = pht_pending_tags + text
}
export function photo_frame_sign(sign: int) -> void { pht_pending_sign = sign }
export function photo_frame_stars() -> void { pht_pending_stars = true }

View file

@ -0,0 +1,80 @@
# grade.ludic - a photograph out of a hundred, for its best subject: SIZE in the frame (40), FRAMING
# (20), LIGHT (20) and THE MOMENT (20). A tag is a tag at any grade; the grade is what a frame is
# worth, never whether it happened.
# the share of the picture's height the subject stands in, 0..1
function pht_size_frac(h: float, d: float) -> float {
let span = 2.0 * d * Math.tan(PhotoLens.fov() * 0.5)
if span < 0.01 { return 1.0 }
return Math.clamp(h / span, 0.0, 1.0)
}
# 0..40: a record shot under 4%, the band that pays a quarter to two thirds, cropped past 85%
export function photo_score_size(frac: float) -> int {
if frac < 0.04 { return int(frac / 0.04 * 6.0) }
if frac < 0.25 { return int(6.0 + (frac - 0.04) / 0.21 * 28.0) }
if frac < 0.70 { return 40 }
if frac < 0.85 { return int(40.0 - (frac - 0.70) / 0.15 * 6.0) }
return int(Math.max(34.0 - (frac - 0.85) / 0.15 * 22.0, 8.0))
}
# 0..20 from how near a thirds point it sits; cut by an edge, a third of that
export function photo_score_frame(sx: float, sy: float, frac: float) -> int {
let m = frac * 0.5
if sx < 0.0 or sx > 1.0 or sy < 0.0 or sy > 1.0 { return 0 }
var best = 9.0
for i in 0 .. 2 {
for j in 0 .. 2 {
let dx = sx - float(i + 1) / 3.0
let dy = sy - float(j + 1) / 3.0
best = Math.min(best, Math.sqrt(dx * dx + dy * dy))
}
}
var v = int(Math.max(1.0 - best / 0.28, 0.0) * 20.0)
if sx < m or sx > 1.0 - m or sy < m or sy > 1.0 - m { v = v / 3 }
return v
}
# The whole score for a subject `h` metres tall standing at (x, y, z), doing something worth
# `moment` (0..20). Composition, light and behaviour are qualities OF a subject, so they are
# weighted by how much of one there is: a deer three pixels wide in perfect light is not a photograph.
export function photo_score(x: float, y: float, z: float, h: float, moment: int) -> int {
let dx = x - PhotoLens.x()
let dy = y + 0.6 - PhotoLens.y()
let dz = z - PhotoLens.z()
let d = Math.max(Math.sqrt(dx * dx + dy * dy + dz * dz), 0.01)
let frac = pht_size_frac(h, d)
if pht_comp == null { pht_blank() }
var sx = 0.5
var sy = 0.5
if PhotoLens.project(x, y + h * 0.5, z) {
sx = PhotoLens.sx()
sy = PhotoLens.sy()
}
pht_comp[0] = float(photo_score_size(frac))
pht_comp[1] = float(photo_score_frame(sx, sy, frac))
if PhotoLens.steady() { pht_comp[1] = Math.min(pht_comp[1] + 5.0, 20.0) }
pht_comp[2] = float(photo_score_light(x, z))
pht_comp[3] = float(Math.clamp(moment, 0, 20))
pht_comp_have = true
let presence = Math.clamp(pht_comp[0] / 40.0 * 1.4, 0.0, 1.0)
if PhotoRules.behaviour() {
pht_comp[0] = pht_comp[0] * 0.5
pht_comp[3] = pht_comp[3] * 2.0
}
return int(pht_comp[0] + (pht_comp[1] + pht_comp[2] + pht_comp[3]) * presence)
}
# the one thing to do differently next time: the worst component, or nothing to fault
export function photo_why_of_comp() -> int {
var worst = PHOTO_WHY_SIZE
var least = pht_comp[0] / 40.0
for k in 1 .. 4 {
let f = pht_comp[k] / 20.0
if f < least {
least = f
worst = k
}
}
if least > 0.8 { return PHOTO_WHY_FINE }
return worst
}

View file

@ -0,0 +1,18 @@
# ludic.photo - the camera's side of a wildlife game: what is in the frame and how good it is, the
# roll it goes onto and what it is worth. The game says where the lens is and what the light is
# doing, hands it the subjects, and writes the picture itself.
module ludic_photo uses ludic_base
numbers float
import "ludic.base"
import "ports.ludic"
import "roll.ludic"
import "queries.ludic"
import "sight.ludic"
import "grade.ludic"
import "light.ludic"
import "frame.ludic"
import "keep.ludic"
import "worth.ludic"
import "order.ludic"
import "slug.ludic"
import "system.ludic"

View file

@ -0,0 +1,81 @@
# keep.ludic - the frame onto the roll, and a frame off it
# The composed frame, kept as `file` on `day` at `hour`: the index, or -1 when the card is full.
# Every subject in it counts as photographed, and its best grade may rise.
export function photo_keep(file: string, day: int, hour: float) -> int {
if pht_n >= pht_max {
pht_fact(PHOTO_FULL, -1)
return -1
}
var firsts = 0
for sp in 0 .. 32 { if (pht_pending_mask & (1 << sp)) != 0 and pht_best[sp] == 0 { firsts = firsts | (1 << sp) } }
let i = photo_add(day, hour, pht_pending_mask, pht_pending_legend, false, pht_pending_tags, file, pht_pending_grade, pht_pending_why)
pht_legend_shot = pht_legend_shot | pht_pending_legend
let f = pht_fact(PHOTO_KEPT, i)
f.mask = pht_pending_mask
f.legend = pht_pending_legend
f.firsts = firsts
f.grade = pht_pending_grade
f.sign = pht_pending_sign
f.stars = pht_pending_stars
f.file = file
f.tags = pht_pending_tags
return i
}
# a frame onto the roll as it stands (a save, an older trip): the index, or -1 when full
export function photo_add(day: int, hour: float, mask: int, legend: int, sold: bool, tags: string, file: string, grade: int, why: int) -> int {
if pht_day == null { pht_blank() }
if pht_n >= pht_max { return -1 }
let i = pht_n
pht_day[i] = day
pht_hour[i] = hour
pht_mask[i] = mask
pht_legend[i] = legend
pht_sold[i] = 0
if sold { pht_sold[i] = 1 }
pht_grade[i] = grade
pht_why[i] = why
push(pht_tags, tags)
push(pht_file, file)
pht_n += 1
pht_species_shot = pht_species_shot | mask
for sp in 0 .. 32 { if (mask & (1 << sp)) != 0 and grade > pht_best[sp] { pht_best[sp] = grade } }
return i
}
# forgetting one: the record goes, the ones after it close up, and the game is told the file
export function photo_remove(i: int) -> void {
if i < 0 or i >= pht_n { return }
let f = pht_fact(PHOTO_DELETED, i)
f.file = pht_file[i]
let tags = new []string
let files = new []string
for k in 0 .. pht_n {
if k == i { continue }
push(tags, pht_tags[k])
push(files, pht_file[k])
}
for k in i .. pht_n - 1 {
pht_day[k] = pht_day[k + 1]
pht_hour[k] = pht_hour[k + 1]
pht_mask[k] = pht_mask[k + 1]
pht_legend[k] = pht_legend[k + 1]
pht_sold[k] = pht_sold[k + 1]
pht_grade[k] = pht_grade[k + 1]
pht_why[k] = pht_why[k + 1]
}
pht_tags = tags
pht_file = files
pht_n -= 1
if pht_cover == i { pht_cover = -1 } else if pht_cover > i { pht_cover -= 1 }
}
export function photo_cover_set(i: int) -> void { pht_cover = i }
# what has been photographed outright (a network's word, a staged trip)
export function photo_record_set(species: int, legends: int) -> void {
pht_species_shot = species
pht_legend_shot = legends
}
export function photo_best_set(sp: int, grade: int) -> void { if sp >= 0 and sp < 32 { pht_best[sp] = grade } }

View file

@ -0,0 +1,19 @@
# light.ludic - 0..20 from the light on a subject at (x, z): the hour after dawn and before dusk pay
# best, flat noon middling, the night by the moon; the weather takes off and adds what it says; and a
# subject between the lens and the sun is backlit
export function photo_score_light(x: float, z: float) -> int {
var v = 10
let h = PhotoLight.hour()
if (h > 5.5 and h < 8.0) or (h > 17.5 and h < 20.0) { v = 20 } else if h > 11.0 and h < 15.0 { v = 9 }
let night = PhotoLight.night()
if night { v = int(3.0 + PhotoLight.moon() * 6.0) }
v = v - PhotoLight.dim() + PhotoLight.bonus()
if not night {
let dx = x - PhotoLens.x()
let dz = z - PhotoLens.z()
let d = Math.max(Math.sqrt(dx * dx + dz * dz), 0.01)
let sun = dx / d * PhotoLight.sun_x() + dz / d * PhotoLight.sun_z()
if sun > 0.72 { v = v - 7 } else if sun < -0.3 { v = v + 2 }
}
return Math.clamp(v, 0, 20)
}

View file

@ -0,0 +1,54 @@
# order.ludic - the roll's order and its pages. Forty frames is a pile in taking order; once a frame
# has a grade the useful question is which are the best. The index is mapped, never the roll resorted.
export const PHOTO_NEWEST: int = 0
export const PHOTO_BEST: int = 1
export const PHOTO_BY_SUBJECT: int = 2
export const PHOTO_PAGE: int = 8
var pht_sort: int = 0
var pht_page: int = 0
var pht_order: []int = null
export function photo_sort() -> int { return pht_sort }
export function photo_sort_next() -> void { pht_sort = (pht_sort + 1) % 3 }
export function photo_sort_set(s: int) -> void { pht_sort = Math.clamp(s, 0, 2) }
# the roll in the chosen order: newest first, best first, or by subject
export function photo_order() -> []int {
pht_order = new []int
for i in 0 .. pht_n { push(pht_order, i) }
if pht_sort == PHOTO_NEWEST {
for i in 0 .. pht_n { pht_order[i] = pht_n - 1 - i }
return pht_order
}
for i in 0 .. pht_n {
var best = i
for k in i + 1 .. pht_n { if pht_before(pht_order[k], pht_order[best]) { best = k } }
let t = pht_order[i]
pht_order[i] = pht_order[best]
pht_order[best] = t
}
return pht_order
}
function pht_before(a: int, b: int) -> bool {
if pht_sort == PHOTO_BEST { return pht_grade[a] > pht_grade[b] }
return pht_mask[a] < pht_mask[b]
}
export function photo_pages() -> int { return (pht_n + PHOTO_PAGE - 1) / PHOTO_PAGE }
export function photo_page_now() -> int { return pht_page }
export function photo_page_step(d: int) -> void { pht_page = Math.clamp(pht_page + d, 0, Math.max(photo_pages() - 1, 0)) }
# a page of the roll, in its order; the page is held inside the roll
export function photo_page_list() -> []int {
let out = new []int
if pht_n == 0 { return out }
let order = photo_order()
if pht_page >= photo_pages() { pht_page = photo_pages() - 1 }
for k in 0 .. PHOTO_PAGE {
let oi = pht_page * PHOTO_PAGE + k
if oi < pht_n { push(out, order[oi]) }
}
return out
}

View file

@ -0,0 +1,6 @@
# ludic.photo - a camera's photographs: composing a frame, grading it on size, framing, light and the
# moment, the roll, its tags and order, what each is worth, and the best frame of every subject.
# Uses ludic.base and nothing else. See README.md.
package "ludic.photo"
version "0.1.0"
kind source

View file

@ -0,0 +1,54 @@
# ports.ludic - what a photograph asks the game: where the lens is and what it can see, the light,
# and the rules of the market. Unbound, the lens sits at the origin looking down -z on flat open
# ground at noon under a clear sky, every point projects to the middle of the frame, and a
# subject's first frame is worth 12 and a legend 120.
export port PhotoLens {
x: fn() -> float = fn pht__zero # the camera's position
y: fn() -> float = fn pht__zero
z: fn() -> float = fn pht__zero
fx: fn() -> float = fn pht__zero # where it points (unit)
fy: fn() -> float = fn pht__zero
fz: fn() -> float = fn pht__back
fov: fn() -> float = fn pht__fov # vertical, radians
project: fn(float, float, float) -> bool = fn pht__centred # into the picture; sx / sy say where
sx: fn() -> float = fn pht__half # 0..1 across
sy: fn() -> float = fn pht__half # 0..1 down
visible: fn(float, float, float, float) -> bool = fn pht__seen # a sphere inside the frustum
ground: fn(float, float) -> float = fn pht__flat
trunk: fn(float, float, float) -> bool = fn pht__clear # (x, z, r): a trunk there
range: fn() -> float = fn pht__range # how far the lens reaches, m
steady: fn() -> bool = fn pht__no # on a tripod
}
export port PhotoLight {
hour: fn() -> float = fn pht__noon
night: fn() -> bool = fn pht__no
moon: fn() -> float = fn pht__zero # 0..1 lit
dim: fn() -> int = fn pht__none # what the weather takes off (a storm 4)
bonus: fn() -> int = fn pht__none # what it adds (the light after a front)
sun_x: fn() -> float = fn pht__zero # toward the sun
sun_z: fn() -> float = fn pht__zero
}
export port PhotoRules {
behaviour: fn() -> bool = fn pht__no # size counts half and the moment double
subject_value: fn(int) -> int = fn pht__twelve # a subject's first frame, before the grade
legend_value: fn(int) -> int = fn pht__legend
mult: fn() -> float = fn pht__one # the lens's worth, times
}
function pht__zero() -> float { return 0.0 }
function pht__back() -> float { return -1.0 }
function pht__fov() -> float { return 1.0 }
function pht__centred(x: float, y: float, z: float) -> bool { return true }
function pht__half() -> float { return 0.5 }
function pht__seen(x: float, y: float, z: float, r: float) -> bool { return true }
function pht__flat(x: float, z: float) -> float { return 0.0 }
function pht__clear(x: float, z: float, r: float) -> bool { return false }
function pht__range() -> float { return 100.0 }
function pht__no() -> bool { return false }
function pht__noon() -> float { return 12.0 }
function pht__none() -> int { return 0 }
function pht__twelve(sp: int) -> int { return 12 }
function pht__legend(sp: int) -> int { return 120 }
function pht__one() -> float { return 1.0 }

View file

@ -0,0 +1,54 @@
# queries.ludic - what a game may ask of the roll and of the frame being composed
export function photo_count() -> int { return pht_n }
export function photo_max() -> int { return pht_max }
export function photo_full() -> bool { return pht_n >= pht_max }
export function photo_day(i: int) -> int { return pht_day[i] }
export function photo_hour(i: int) -> float { return pht_hour[i] }
export function photo_mask(i: int) -> int { return pht_mask[i] }
export function photo_legend_mask(i: int) -> int { return pht_legend[i] }
export function photo_sold(i: int) -> bool { return pht_sold[i] != 0 }
export function photo_grade(i: int) -> int { return pht_grade[i] }
export function photo_why(i: int) -> int { return pht_why[i] }
export function photo_tags(i: int) -> string { return pht_tags[i] }
export function photo_file(i: int) -> string { return pht_file[i] }
# a frame with a subject in it was graded; a view is not
export function photo_graded(i: int) -> bool { return pht_mask[i] != 0 }
export function photo_cover() -> int { return pht_cover }
export function photo_species_shot() -> int { return pht_species_shot }
export function photo_legend_shot() -> int { return pht_legend_shot }
export function photo_best(sp: int) -> int {
if sp < 0 or sp >= len(pht_best) { return 0 }
return pht_best[sp]
}
export function photo_pending_tags() -> string { return pht_pending_tags }
export function photo_pending_mask() -> int { return pht_pending_mask }
export function photo_pending_legend() -> int { return pht_pending_legend }
export function photo_pending_grade() -> int { return pht_pending_grade }
export function photo_pending_why() -> int { return pht_pending_why }
export function photo_pending_sign() -> int { return pht_pending_sign }
export function photo_pending_stars() -> bool { return pht_pending_stars }
# the last subject's four components (size of 40, framing, light and the moment of 20 each), for a meter
export function photo_comp(k: int) -> float { return pht_comp[k] }
export function photo_comp_have() -> bool { return pht_comp_have }
# the band a grade falls in: 0 a record shot, 1 fair, 2 good, 3 fine, 4 one for the wall
export function photo_band(g: int) -> int {
if g >= 90 { return 4 }
if g >= 75 { return 3 }
if g >= 55 { return 2 }
if g >= 30 { return 1 }
return 0
}
# what the grade does to a frame's price: a quarter for a record shot, four times for the wall
export function photo_grade_mult(g: int) -> float {
let b = photo_band(g)
if b == 4 { return 4.0 }
if b == 3 { return 2.4 }
if b == 2 { return 1.5 }
if b == 1 { return 1.0 }
return 0.35
}

View file

@ -0,0 +1,97 @@
# roll.ludic - the roll of kept frames, the frame being composed, what has ever been photographed,
# and the facts
export const PHOTO_WHY_NONE: int = -1 # no subject: nothing to say
export const PHOTO_WHY_SIZE: int = 0 # too far off - get closer, or more lens
export const PHOTO_WHY_FRAME: int = 1 # badly placed in the frame
export const PHOTO_WHY_LIGHT: int = 2 # the light was wrong
export const PHOTO_WHY_MOMENT: int = 3 # it was not doing anything
export const PHOTO_WHY_FINE: int = 4 # nothing to fault
export const PHOTO_KEPT: int = 0 # a frame went onto the roll
export const PHOTO_FULL: int = 1 # the card is full
export const PHOTO_DELETED: int = 2 # file: the picture to remove
export const PHOTO_SOLD: int = 3 # v: what it fetched
export property PhotoFact {
what: int = 0
index: int = -1
mask: int = 0 # subjects in it, a bit each
legend: int = 0 # which of them were legends
firsts: int = 0 # subjects never photographed before this frame
grade: int = 0
sign: int = -1 # a sign in the frame, or -1
stars: bool = false
file: string = ""
tags: string = ""
v: int = 0
}
var pht_max: int = 40
var pht_n: int = 0
var pht_day: []int = null
var pht_hour: []float = null
var pht_mask: []int = null
var pht_legend: []int = null
var pht_sold: []int = null
var pht_grade: []int = null
var pht_why: []int = null
var pht_tags: []string = null
var pht_file: []string = null
var pht_best: []int = null # the best grade yet, per subject
var pht_species_shot: int = 0 # every subject ever photographed, sold or deleted
var pht_legend_shot: int = 0
var pht_cover: int = -1 # the one frame a game shows for the whole roll
var pht_pending_tags: string = ""
var pht_pending_mask: int = 0
var pht_pending_legend: int = 0
var pht_pending_grade: int = 0
var pht_pending_why: int = -1
var pht_pending_sign: int = -1
var pht_pending_stars: bool = false
var pht_comp: []float = null # size, framing, light, moment of the last subject scored
var pht_comp_have: bool = false
var pht_fact_q: Queue<PhotoFact> = null
export function photo_facts() -> Queue<PhotoFact> {
if pht_fact_q == null { pht_fact_q = queue_new("photo.facts") }
return pht_fact_q
}
function pht_fact(what: int, i: int) -> PhotoFact {
let f = new PhotoFact
f.what = what
f.index = i
q_push(photo_facts(), f)
return f
}
function pht_ints(n: int) -> []int {
let out = new []int
for i in 0 .. n { push(out, 0) }
return out
}
function pht_floats(n: int) -> []float {
let out = new []float
for i in 0 .. n { push(out, 0.0) }
return out
}
# an empty roll of `max` frames
function pht_blank() -> void {
pht_n = 0
pht_day = pht_ints(pht_max)
pht_hour = pht_floats(pht_max)
pht_mask = pht_ints(pht_max)
pht_legend = pht_ints(pht_max)
pht_sold = pht_ints(pht_max)
pht_grade = pht_ints(pht_max)
pht_why = pht_ints(pht_max)
pht_tags = new []string
pht_file = new []string
pht_best = pht_ints(32)
pht_comp = pht_floats(4)
pht_comp_have = false
}

View file

@ -0,0 +1,36 @@
# sight.ludic - in the picture, within reach, and actually seen. A frustum says a subject is in FRONT
# of the lens, not that the lens can see it: the ray walks the ground and the trunks between. It is
# forgiving on purpose - the subject passes if its body or its head is clear.
const PHT_STEP: float = 3.0 # metres between the ray's samples
function pht_ray_point(x: float, y: float, z: float) -> bool {
let cx = PhotoLens.x()
let cy = PhotoLens.y()
let cz = PhotoLens.z()
let d = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy) + (z - cz) * (z - cz))
if d < 3.0 { return true }
let n = int(Math.clamp(d / PHT_STEP, 6.0, 120.0))
for s in 1 .. n {
let f = float(s) / float(n)
let px = Math.lerp(cx, x, f)
let py = Math.lerp(cy, y, f)
let pz = Math.lerp(cz, z, f)
let g = PhotoLens.ground(px, pz)
# the ground with 35 cm of slack; a trunk once past the lens's own few metres
if g - 0.35 > py { return false }
if d * f > 6.0 and py - g < 7.0 and PhotoLens.trunk(px, pz, 0.45) { return false }
}
return true
}
# is (x, y, z) in the picture, within `far`, and can the lens see it?
export function photo_in_view(x: float, y: float, z: float, far: float) -> bool {
let dx = x - PhotoLens.x()
let dy = y - PhotoLens.y()
let dz = z - PhotoLens.z()
let d = Math.sqrt(dx * dx + dy * dy + dz * dz)
if d > far or d < 0.5 { return false }
if (dx * PhotoLens.fx() + dy * PhotoLens.fy() + dz * PhotoLens.fz()) / d < 0.72 { return false }
if not PhotoLens.visible(x, y, z, 1.0) { return false }
return pht_ray_point(x, y, z) or pht_ray_point(x, y + 0.55, z)
}

View file

@ -0,0 +1,31 @@
# slug.ludic - a frame's tags as a file name: lower case letters and digits, words joined by dashes,
# at most 24 characters, so a folder of photographs browses well
const PHT_ALPHA: string = "0123456789abcdefghijklmnopqrstuvwxyz"
export function photo_slug(tags: string) -> string {
var out = ""
var n = 0
var i = 0
var last_dash = true
while i < len(tags) and n < 24 {
var c = tags[i]
if c >= 65 and c <= 90 { c = c + 32 }
var k = -1
if c >= 48 and c <= 57 { k = c - 48 }
if c >= 97 and c <= 122 { k = c - 97 + 10 }
if k >= 0 {
let ch: string = PHT_ALPHA[k..k + 1]
out = out + ch
last_dash = false
n += 1
} else if (c == 32 or c == 44) and not last_dash and n > 0 {
out = out + "-"
last_dash = true
n += 1
}
i += 1
}
while Text.ends_with(out, "-") { out = Text.slice(out, 0, len(out) - 1) }
if len(out) == 0 { return "photo" }
return out
}

View file

@ -0,0 +1,62 @@
# system.ludic - the camera as a system: the roll and what has been photographed in its own save
# section, by key. The best per subject follows from the frames, as a buyer would judge them.
export function photo_config(max: int) -> void {
pht_max = Math.max(max, 1)
photo_reset()
}
export function photo_reset() -> void {
pht_blank()
pht_species_shot = 0
pht_legend_shot = 0
pht_cover = -1
pht_page = 0
photo_frame_begin()
q_clear(photo_facts())
}
export function photo_save() -> Val {
let v = Value.object()
let l = Value.list()
for i in 0 .. pht_n {
let o = Value.object()
sv_put_int(o, "day", pht_day[i])
sv_put_float(o, "hour", pht_hour[i])
sv_put_int(o, "mask", pht_mask[i])
sv_put_int(o, "legend", pht_legend[i])
sv_put_bool(o, "sold", pht_sold[i] != 0)
sv_put_str(o, "tags", pht_tags[i])
sv_put_str(o, "file", pht_file[i])
sv_put_int(o, "grade", pht_grade[i])
sv_put_int(o, "why", pht_why[i])
Value.add(l, o)
}
Value.put(v, "frames", l)
sv_put_int(v, "shot", pht_species_shot)
sv_put_int(v, "legend_shot", pht_legend_shot)
sv_put_int(v, "cover", pht_cover)
sv_put_int(v, "sort", pht_sort)
return v
}
export function photo_load(v: Val, version: int) -> void {
photo_reset()
if Value.has(v, "frames") != 0 {
let l = Value.get(v, "frames")
for i in 0 .. Value.count(l) {
let o = Value.at(l, i)
photo_add(sv_int(o, "day", 1), sv_float(o, "hour", 0.0), sv_int(o, "mask", 0), sv_int(o, "legend", 0), sv_bool(o, "sold", false), sv_str(o, "tags", ""), sv_str(o, "file", ""), sv_int(o, "grade", 40), sv_int(o, "why", PHOTO_WHY_NONE))
}
}
photo_record_set(pht_species_shot | sv_int(v, "shot", 0), sv_int(v, "legend_shot", 0))
pht_cover = sv_int(v, "cover", -1)
photo_sort_set(sv_int(v, "sort", PHOTO_NEWEST))
}
export function photo_system() -> System {
let s = system_new("photo", PH_COMMIT)
s.reset = fn photo_reset
s.save = fn photo_save
s.load = fn photo_load
return s
}

View file

@ -0,0 +1,234 @@
# photo_test.ludic - a lens at the origin looking down -z over flat ground, a ridge 45 m out on the
# left and a trunk 30 m out on the right: what is in the frame, the grade's four parts, the frame's
# best subject, keeping, the card filling, value and selling, forgetting a frame, the roll's order
# and pages, the file name and the save
import "ludic.photo"
import "ludic.base"
program PhotoTest {
numbers float
var hour: float = 12.0
var night: bool = false
var moon: float = 0.0
var dim: int = 0
var sun_z: float = 0.0
var behaviour: bool = false
var sx: float = 0.5
var sy: float = 0.5
function fk_y() -> float { return 1.5 }
function fk_fz() -> float { return -1.0 }
function fk_fov() -> float { return 0.8 }
# a pinhole at the origin: across by x over the distance, down by the height over it
function fk_project(x: float, y: float, z: float) -> bool {
let d = Math.max(-z, 0.5)
sx = 0.5 + x / d
sy = 0.5 - (y - 1.5) / d
return z < 0.0
}
function fk_sx() -> float { return sx }
function fk_sy() -> float { return sy }
function fk_ground(x: float, z: float) -> float {
if x < 0.0 and z < -45.0 and z > -50.0 { return 10.0 }
return 0.0
}
function fk_trunk(x: float, z: float, r: float) -> bool { return Math.abs(x - 3.75) < 1.6 and Math.abs(z + 30.0) < 1.6 }
function fk_range() -> float { return 80.0 }
function fk_hour() -> float { return hour }
function fk_night() -> bool { return night }
function fk_moon() -> float { return moon }
function fk_dim() -> int { return dim }
function fk_sun_z() -> float { return sun_z }
function fk_behaviour() -> bool { return behaviour }
function fk_value(sp: int) -> int { return 10 + sp }
bind PhotoLens { y: fn fk_y, fz: fn fk_fz, fov: fn fk_fov, project: fn fk_project, sx: fn fk_sx, sy: fn fk_sy, ground: fn fk_ground, trunk: fn fk_trunk, range: fn fk_range }
bind PhotoLight { hour: fn fk_hour, night: fn fk_night, moon: fn fk_moon, dim: fn fk_dim, sun_z: fn fk_sun_z }
bind PhotoRules { behaviour: fn fk_behaviour, subject_value: fn fk_value }
function fresh() -> void {
photo_config(4)
hour = 12.0
night = false
dim = 0
sun_z = 0.0
behaviour = false
}
function facts() -> []PhotoFact { return q_drain(photo_facts()) }
function count(fs: []PhotoFact, what: int) -> int {
var n = 0
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
# a deer-sized subject straight ahead at `d` metres, doing something worth `moment`
function shoot(sp: int, x: float, d: float, h: float, moment: int) -> bool {
photo_frame_begin()
return photo_frame_subject(sp, "deer", x, 0.0, -d, h, false, moment)
}
test "a subject ahead and in reach is in the frame; behind, too far, over a ridge or a trunk it is not" {
fresh()
expect(shoot(1, 0.0, 20.0, 1.1, 10))
expect_eq(photo_pending_mask(), 2)
expect(photo_pending_tags() == "deer")
expect(photo_pending_grade() > 0)
expect(not shoot(1, 0.0, -20.0, 1.1, 10))
expect(not shoot(1, 0.0, 90.0, 1.1, 10))
expect(not shoot(1, -2.0, 60.0, 1.1, 10))
expect(not shoot(1, 5.0, 40.0, 1.1, 10))
expect_eq(photo_pending_mask(), 0)
}
test "size: a record shot is a record shot, and the band that pays is a quarter to two thirds" {
expect(photo_score_size(0.02) < 6)
expect_eq(photo_score_size(0.5), 40)
expect(photo_score_size(0.95) < 34)
expect(photo_score_frame(1.0 / 3.0, 1.0 / 3.0, 0.1) > photo_score_frame(0.5, 0.5, 0.1))
expect(photo_score_frame(0.02, 0.4, 0.3) < 7)
expect_eq(photo_score_frame(1.4, 0.4, 0.1), 0)
}
test "the light: gold after dawn, flat at noon, the moon at night, the weather, and backlit" {
fresh()
hour = 6.5
expect_eq(photo_score_light(0.0, -20.0), 20)
hour = 12.0
expect_eq(photo_score_light(0.0, -20.0), 9)
dim = 4
expect_eq(photo_score_light(0.0, -20.0), 5)
dim = 0
sun_z = -1.0
expect_eq(photo_score_light(0.0, -20.0), 2)
sun_z = 0.0
night = true
moon = 1.0
expect_eq(photo_score_light(0.0, -20.0), 9)
}
test "the moment counts by how much subject there is, and a judge of behaviour doubles it" {
fresh()
shoot(1, 0.0, 70.0, 0.3, 20)
let tiny = photo_pending_grade()
shoot(1, 0.0, 6.0, 1.1, 6)
let big = photo_pending_grade()
expect(big > tiny)
expect(tiny < 20)
shoot(1, 0.0, 6.0, 1.1, 20)
let busy = photo_pending_grade()
behaviour = true
shoot(1, 0.0, 6.0, 1.1, 20)
expect(photo_pending_grade() > busy - 21)
expect_near(photo_comp(3), 40.0, 0.01)
}
test "the frame takes its best subject, tags each kind once and every legend" {
fresh()
photo_frame_begin()
photo_frame_subject(2, "elk", 0.0, 0.0, -60.0, 1.9, false, 6)
let far = photo_pending_grade()
photo_frame_subject(2, "elk", 1.0, 0.0, -8.0, 1.9, false, 17)
photo_frame_subject(4, "the Ghost Elk", -1.0, 0.0, -9.0, 2.2, true, 20)
photo_frame_tag("Maroon Lake")
expect(photo_pending_grade() > far)
expect(photo_pending_tags() == "elk, the Ghost Elk, Maroon Lake")
expect_eq(photo_pending_mask(), 4 + 16)
expect_eq(photo_pending_legend(), 16)
expect(photo_pending_why() >= PHOTO_WHY_SIZE)
}
test "a kept frame is on the roll, its subjects are photographed for good, and a full card says so" {
fresh()
shoot(3, 0.0, 10.0, 1.1, 15)
let g = photo_pending_grade()
expect_eq(photo_keep("a.png", 2, 7.5), 0)
let fs = facts()
expect_eq(count(fs, PHOTO_KEPT), 1)
expect_eq(fs[0].firsts, 8)
expect_eq(photo_species_shot(), 8)
expect_eq(photo_best(3), g)
expect_eq(photo_day(0), 2)
shoot(3, 0.0, 10.0, 1.1, 15)
photo_keep("b.png", 2, 8.0)
expect_eq(facts()[0].firsts, 0)
photo_keep("c.png", 2, 8.0)
photo_keep("d.png", 2, 8.0)
expect(photo_full())
expect_eq(photo_keep("e.png", 2, 8.0), -1)
expect_eq(count(facts(), PHOTO_FULL), 1)
}
test "the first frame of a subject is worth its value on the grade's curve; sold is sold" {
fresh()
photo_add(1, 9.0, 2, 0, false, "fox", "f.png", 60, PHOTO_WHY_NONE)
photo_add(1, 9.5, 2, 2, false, "fox", "g.png", 95, PHOTO_WHY_FINE)
photo_add(1, 9.5, 0, 0, false, "the valley", "h.png", 0, PHOTO_WHY_NONE)
expect_eq(photo_value(0), int(11.0 * 1.5))
expect_eq(photo_value(1), int(float(11 + 120) * 4.0))
expect_eq(photo_value(2), 0)
let total = photo_sell_all()
expect_eq(total, int(11.0 * 1.5) + int(120.0 * 4.0))
expect_eq(count(facts(), PHOTO_SOLD), 2)
expect(photo_sold(0))
expect_eq(photo_value(0), 0)
photo_add(1, 10.0, 2, 0, false, "fox", "i.png", 60, PHOTO_WHY_NONE)
expect_eq(photo_value(3), 0)
}
test "forgetting a frame closes the roll up, moves the cover, and names the file" {
fresh()
photo_add(1, 9.0, 2, 0, false, "a", "a.png", 10, 0)
photo_add(1, 9.0, 4, 0, false, "b", "b.png", 20, 0)
photo_add(1, 9.0, 8, 0, false, "c", "c.png", 30, 0)
photo_cover_set(2)
photo_remove(0)
expect_eq(photo_count(), 2)
expect(photo_tags(0) == "b")
expect_eq(photo_grade(1), 30)
expect_eq(photo_cover(), 1)
let fs = facts()
expect(fs[0].file == "a.png")
expect_eq(photo_species_shot(), 14)
}
test "the roll in order: newest first, best first, by subject, and a page at a time" {
photo_config(20)
for i in 0 .. 10 { photo_add(1, 9.0, 1 << (i % 3), 0, false, "x", "x.png", (i * 37) % 100, 0) }
expect_eq(photo_order()[0], 9)
photo_sort_next()
let best = photo_order()
expect(photo_grade(best[0]) >= photo_grade(best[1]))
expect(photo_grade(best[1]) >= photo_grade(best[9]))
photo_sort_next()
expect(photo_mask(photo_order()[0]) <= photo_mask(photo_order()[9]))
expect_eq(photo_pages(), 2)
expect_eq(len(photo_page_list()), 8)
photo_page_step(5)
expect_eq(photo_page_now(), 1)
expect_eq(len(photo_page_list()), 2)
}
test "a file name from the tags" {
expect(photo_slug("Elk, the Ghost Elk") == "elk-the-ghost-elk")
expect(photo_slug("") == "photo")
expect(photo_slug("a very long list of names past twenty") == "a-very-long-list-of-name")
}
test "the roll and what was ever photographed come back from the save" {
fresh()
photo_add(3, 18.25, 2, 2, true, "fox", "f.png", 80, PHOTO_WHY_LIGHT)
photo_record_set(2 | 64, 2)
photo_cover_set(0)
let v = photo_save()
photo_reset()
expect_eq(photo_count(), 0)
photo_load(v, 1)
expect_eq(photo_count(), 1)
expect_near(photo_hour(0), 18.25, 0.01)
expect(photo_sold(0))
expect_eq(photo_why(0), PHOTO_WHY_LIGHT)
expect_eq(photo_species_shot(), 66)
expect_eq(photo_legend_shot(), 2)
expect_eq(photo_best(1), 80)
expect_eq(photo_cover(), 0)
}
}

View file

@ -0,0 +1,38 @@
# worth.ludic - what a buyer pays for a frame: the first frame of each subject and every legend,
# on the grade's curve and the lens's worth; sold is sold
export function photo_value(i: int) -> int {
if i < 0 or i >= pht_n or pht_sold[i] != 0 { return 0 }
var v = 0
for sp in 0 .. 32 {
if (pht_mask[i] & (1 << sp)) == 0 { continue }
var first = true
for k in 0 .. pht_n { if k != i and pht_sold[k] != 0 and (pht_mask[k] & (1 << sp)) != 0 { first = false } }
if first { v += PhotoRules.subject_value(sp) }
if (pht_legend[i] & (1 << sp)) != 0 { v += PhotoRules.legend_value(sp) }
}
if v > 0 { v = int(float(v) * (PhotoRules.mult() * photo_grade_mult(pht_grade[i]))) }
return v
}
# every frame with a price, sold: the total, and a fact a frame
export function photo_sell_all() -> int {
var total = 0
for i in 0 .. pht_n {
let v = photo_value(i)
if v > 0 {
total += v
pht_sold[i] = 1
let f = pht_fact(PHOTO_SOLD, i)
f.v = v
f.mask = pht_mask[i]
}
}
return total
}
# what the roll would fetch now
export function photo_worth() -> int {
var total = 0
for i in 0 .. pht_n { total += photo_value(i) }
return total
}