diff --git a/packages/README.md b/packages/README.md index 29f19126..158216c5 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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 | diff --git a/packages/ludic.photo/README.md b/packages/ludic.photo/README.md new file mode 100644 index 00000000..4565d4f1 --- /dev/null +++ b/packages/ludic.photo/README.md @@ -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` | `{ 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. diff --git a/packages/ludic.photo/frame.ludic b/packages/ludic.photo/frame.ludic new file mode 100644 index 00000000..cab65c94 --- /dev/null +++ b/packages/ludic.photo/frame.ludic @@ -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 } diff --git a/packages/ludic.photo/grade.ludic b/packages/ludic.photo/grade.ludic new file mode 100644 index 00000000..f28dc590 --- /dev/null +++ b/packages/ludic.photo/grade.ludic @@ -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 +} diff --git a/packages/ludic.photo/index.ludic b/packages/ludic.photo/index.ludic new file mode 100644 index 00000000..70e6946a --- /dev/null +++ b/packages/ludic.photo/index.ludic @@ -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" diff --git a/packages/ludic.photo/keep.ludic b/packages/ludic.photo/keep.ludic new file mode 100644 index 00000000..b272af4c --- /dev/null +++ b/packages/ludic.photo/keep.ludic @@ -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 } } diff --git a/packages/ludic.photo/light.ludic b/packages/ludic.photo/light.ludic new file mode 100644 index 00000000..0ecec872 --- /dev/null +++ b/packages/ludic.photo/light.ludic @@ -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) +} diff --git a/packages/ludic.photo/order.ludic b/packages/ludic.photo/order.ludic new file mode 100644 index 00000000..677db200 --- /dev/null +++ b/packages/ludic.photo/order.ludic @@ -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 +} diff --git a/packages/ludic.photo/package.ludic b/packages/ludic.photo/package.ludic new file mode 100644 index 00000000..ee6e3f2f --- /dev/null +++ b/packages/ludic.photo/package.ludic @@ -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 diff --git a/packages/ludic.photo/ports.ludic b/packages/ludic.photo/ports.ludic new file mode 100644 index 00000000..0d662bcd --- /dev/null +++ b/packages/ludic.photo/ports.ludic @@ -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 } diff --git a/packages/ludic.photo/queries.ludic b/packages/ludic.photo/queries.ludic new file mode 100644 index 00000000..14080265 --- /dev/null +++ b/packages/ludic.photo/queries.ludic @@ -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 +} diff --git a/packages/ludic.photo/roll.ludic b/packages/ludic.photo/roll.ludic new file mode 100644 index 00000000..f2e65c6f --- /dev/null +++ b/packages/ludic.photo/roll.ludic @@ -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 = null + +export function photo_facts() -> Queue { + 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 +} diff --git a/packages/ludic.photo/sight.ludic b/packages/ludic.photo/sight.ludic new file mode 100644 index 00000000..42d1df43 --- /dev/null +++ b/packages/ludic.photo/sight.ludic @@ -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) +} diff --git a/packages/ludic.photo/slug.ludic b/packages/ludic.photo/slug.ludic new file mode 100644 index 00000000..2c648871 --- /dev/null +++ b/packages/ludic.photo/slug.ludic @@ -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 +} diff --git a/packages/ludic.photo/system.ludic b/packages/ludic.photo/system.ludic new file mode 100644 index 00000000..d9f777d6 --- /dev/null +++ b/packages/ludic.photo/system.ludic @@ -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 +} diff --git a/packages/ludic.photo/tests/photo_test.ludic b/packages/ludic.photo/tests/photo_test.ludic new file mode 100644 index 00000000..a6bd8867 --- /dev/null +++ b/packages/ludic.photo/tests/photo_test.ludic @@ -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) + } +} diff --git a/packages/ludic.photo/worth.ludic b/packages/ludic.photo/worth.ludic new file mode 100644 index 00000000..098ae917 --- /dev/null +++ b/packages/ludic.photo/worth.ludic @@ -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 +}