diff --git a/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt new file mode 100644 index 000000000..d2fcdf3eb --- /dev/null +++ b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright(c) 2014 by Julieta Ulanovsky + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta new file mode 100644 index 000000000..c2ea6a451 --- /dev/null +++ b/Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dc84a6b2454445f9be69af26e86eb90c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Fonts/Ubuntu/UFL-1.0.txt b/Assets/Fonts/Ubuntu/UFL-1.0.txt new file mode 100644 index 000000000..ae78a8f94 --- /dev/null +++ b/Assets/Fonts/Ubuntu/UFL-1.0.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta b/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta new file mode 100644 index 000000000..33790c1ab --- /dev/null +++ b/Assets/Fonts/Ubuntu/UFL-1.0.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 331370618a0a473fb6a6837985b38a35 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Sprites/Attribution.txt b/Assets/Resources/Sprites/Attribution.txt index 666742406..8ca89fb1b 100644 --- a/Assets/Resources/Sprites/Attribution.txt +++ b/Assets/Resources/Sprites/Attribution.txt @@ -2,8 +2,7 @@ Noun Project Tornado by Bart Laubsch Alien Abduction by Bart Laubsch -Hurricane by icon 54 from -Smashicons Space by Ben Davis +Hurricane by icon 54 from Smashicons Space by Ben Davis Space 2 - Solid by Ben Davis Filter by Karthik Aathis Search by il Capitano diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs new file mode 100644 index 000000000..45054187b --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +// The third-party attributions the shipped game must show the player. One list, owned here: the credits screen only +// renders it, and CreditsTests holds it against the licence files in Assets. Unity-free so the headless build checks it. +public class Credit +{ + public string Name; + public string Licence; + // What the licence requires shown: copyright holders, and any change notice. + public string Notice; + // The licence file in the repo that carries the full text and that this entry answers for. + public string LicenceFile; +} + +public static class Credits +{ + public const string TwemojiFile = "Assets/Fonts/Emoji/LICENSE.txt"; + public const string PlaydeadFile = "Assets/Plugins/Playdead/LICENSE.txt"; + public const string NounProjectFile = "Assets/Resources/Sprites/Attribution.txt"; + public const string LiberationFile = "Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt"; + // Fonts state their holder and licence in their own name tables; the licence texts shipped beside them are these. + public const string UbuntuLicenceFile = "Assets/Fonts/Ubuntu/UFL-1.0.txt"; + public const string MontserratLicenceFile = "Assets/Fonts/Ubuntu/Montserrat-OFL-1.1.txt"; + public const string UniRxFile = "Assets/Plugins/UniRx/ReadMe.txt"; + public const string AmbientCgFile = "Assets/Materials/PBR/SOURCES.md"; + public const string KdTreeFile = "Assets/Scripts/ServerShared/NIH/KDTree/KDTree.cs"; + public const string SimplexNoiseFile = "Assets/Plugins/GPU Noise/SimplexNoise2D.cginc"; + // Works whose notice is only a source-file header; each answers through the file whose header states it. + public const string UnityShaderFile = "Assets/Shaders/Particle Standard Unlit.shader"; + public const string AdditiveUiFile = "Assets/Shaders/AdditiveUI.shader"; + public const string AcesFile = "Assets/Shaders/CustomDoF/ACES.hlsl"; + public const string ClassicNoiseFile = "Assets/Plugins/Celestial Body/Scripts/Shaders/Includes/SimplexNoise2.cginc"; + // The first line of the attribution file; the icon credits are the lines after it. + public const string NounProjectHeading = "Noun Project"; + // Where the player build loads the Noun Project credit lines from (Resources path, no extension). + public const string NounProjectResource = "Sprites/Attribution"; + + // The full list. The Noun Project icon credits are the lines of its attribution file, whose text the caller supplies + // (Unity reads it as a Resources TextAsset; tests read it from disk), so the file stays the one copy of those names. + public static IReadOnlyList All(string nounProjectAttribution) + { + var credits = new List + { + new Credit + { + Name = "Twemoji", + Licence = "CC BY 4.0", + Notice = "Copyright (c) 2022-present Jason Sofonia & Justine De Caires; copyright (c) 2014-2021 Twitter. " + + "Graphics licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). " + + "Converted to a colour font; the artwork itself is not edited.", + LicenceFile = TwemojiFile + }, + new Credit + { + Name = "Playdead temporal anti-aliasing", + Licence = "MIT", + Notice = "Copyright (c) 2015 Playdead", + LicenceFile = PlaydeadFile + }, + new Credit + { + Name = "Liberation Sans", + Licence = "SIL Open Font License 1.1", + Notice = "Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine. " + + "Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.", + LicenceFile = LiberationFile + }, + new Credit + { + Name = "Ubuntu and Ubuntu Mono fonts", + Licence = "Ubuntu Font Licence 1.0", + Notice = "Copyright 2011 Canonical Ltd.", + LicenceFile = UbuntuLicenceFile + }, + new Credit + { + Name = "Montserrat", + Licence = "SIL Open Font License 1.1", + Notice = "Copyright(c) 2014 by Julieta Ulanovsky", + LicenceFile = MontserratLicenceFile + }, + new Credit + { + Name = "UniRx", + Licence = "MIT", + Notice = "Created by Yoshifumi Kawai (neuecc)", + LicenceFile = UniRxFile + }, + new Credit + { + Name = "KDTree", + Licence = "MIT", + Notice = "Copyright (c) 2018 Vili VolĨini / viliwonka", + LicenceFile = KdTreeFile + }, + new Credit + { + Name = "webgl-noise simplex noise", + Licence = "MIT", + Notice = "Copyright (C) 2011 Ashima Arts. Author: Ian McEwan, Ashima Arts " + + "(https://github.com/ashima/webgl-noise). Translated to Unity shaders and modified by Keijiro Takahashi " + + "(https://github.com/keijiro/NoiseShader).", + LicenceFile = SimplexNoiseFile + }, + new Credit + { + Name = "Celestial Body noise shaders", + Licence = "MIT", + Notice = "Copyright (C) 2011 Ashima Arts. Copyright (C) 2011 by Ashima Arts (Simplex noise). " + + "Copyright (C) 2011 by Stefan Gustavson (Classic noise).", + LicenceFile = ClassicNoiseFile + }, + new Credit + { + Name = "Unity built-in shaders", + Licence = "MIT", + Notice = "Copyright (c) 2016 Unity Technologies. Shader source adapted from Unity's built-in shaders " + + "(Particle Standard Unlit, StretchedSkybox, MaskedWorldSpaceUI, MaskedWorldSpaceAdditiveUI).", + LicenceFile = UnityShaderFile + }, + new Credit + { + Name = "AdditiveUI shaders", + Licence = "MIT for the Unity Technologies portion; the Supyrb portion states no licence", + Notice = "Copyright (c) 2017 Supyrb. All rights reserved. Author: Johannes Deml. " + + "Built on Unity's built-in UI shader, copyright (c) 2016 Unity Technologies, MIT.", + LicenceFile = AdditiveUiFile + }, + new Credit + { + Name = "ACES colour transforms", + Licence = "Academy Color Encoding System licence", + Notice = "Copyright 2015 Academy of Motion Picture Arts and Sciences (A.M.P.A.S.). " + + "Portions contributed by others as indicated. All rights reserved. " + + "Source: https://github.com/ampas/aces-dev", + LicenceFile = AcesFile + }, + new Credit + { + Name = "ambientCG PBR materials", + Licence = "CC0 1.0 Universal", + Notice = "Texture sets from ambientCG (https://ambientcg.com), public domain", + LicenceFile = AmbientCgFile + }, + new Credit + { + Name = "Noun Project icons", + Licence = "Noun Project attribution licence", + Notice = string.Join("\n", NounProjectLines(nounProjectAttribution)), + LicenceFile = NounProjectFile + } + }; + Validate(credits); + return credits; + } + + // The icon credit lines of the attribution file: everything after its "Noun Project" heading, one line per icon (the + // file's "Other" sub-heading and its lines follow in the same list). A file that does not open with the heading is + // refused rather than having its first line dropped. + public static IEnumerable NounProjectLines(string attribution) + { + var lines = (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray(); + if (lines.Length < 2 || lines[0] != NounProjectHeading) + throw new InvalidOperationException($"{NounProjectFile} must open with the heading '{NounProjectHeading}' followed by icon credits"); + return lines.Skip(1); + } + + // Throws on an entry a player-facing screen could not honestly show. + public static void Validate(IReadOnlyList credits) + { + foreach (var credit in credits) + if (string.IsNullOrWhiteSpace(credit.Name) || string.IsNullOrWhiteSpace(credit.Licence) || + string.IsNullOrWhiteSpace(credit.Notice) || string.IsNullOrWhiteSpace(credit.LicenceFile)) + throw new InvalidOperationException($"Credit '{credit.Name}' is missing its name, licence, notice or licence file"); + var duplicate = credits.GroupBy(credit => credit.LicenceFile).FirstOrDefault(group => group.Count() > 1); + if (duplicate != null) throw new InvalidOperationException($"Licence file {duplicate.Key} is credited twice"); + } +} diff --git a/Assets/Scripts/ServerShared/Credits.cs.meta b/Assets/Scripts/ServerShared/Credits.cs.meta new file mode 100644 index 000000000..e88730bb6 --- /dev/null +++ b/Assets/Scripts/ServerShared/Credits.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3333d3c0203441fbadd64fad27b60e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 9eef387ca..e7aba3357 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -7,7 +7,9 @@ using System.Threading; using System.Threading.Tasks; using TMPro; using UniRx; +using UniRx.Triggers; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.SceneManagement; using UnityEngine.UI; using static CultMath.math; @@ -136,9 +138,79 @@ public class MainMenu : MonoBehaviour ShowSettings(); Fade(true); }); + _nextMenu.panel.AddButton("Credits", + () => + { + if (ShowCredits()) Fade(true); + }); _nextMenu.panel.AddButton("Quit", Application.Quit); } + // The third-party attributions the build must show, one button per entry; the selected entry's name, licence and one + // row per notice line fill the detail pane. The list is Credits.All; this only renders it. The screen follows the + // New Game screen: list rows are out of automatic navigation, the focused Back button takes Up and Down to step the + // selection and Cancel to leave, and a click on empty space does not drop that focus. + // False when the list could not be built and a dialog says why; the menu stays where it was. + private bool ShowCredits() + { + var attribution = Resources.Load(Credits.NounProjectResource); + if (attribution == null) + { + Refuse("Credits are unavailable", $"{Credits.NounProjectResource} is missing from this build."); + return false; + } + IReadOnlyList credits; + try { credits = Credits.All(attribution.text); } + catch (InvalidOperationException error) + { + Refuse("Credits are unavailable", error.Message); + return false; + } + Next("credits"); + var list = _nextMenu.panel; + var detail = _nextMenu.detail; + detail.gameObject.SetActive(true); + var focus = new FocusHold(); + var selected = -1; + Button back = null; + void Leave() + { + FocusHold.Focus(null); + focus.Release(); + ShowMain(); + Fade(false); + } + void Select(int index) + { + selected = Mathf.Clamp(index, 0, credits.Count - 1); + var credit = credits[selected]; + detail.Clear(); + detail.Title.text = list.Title.text; + detail.Title.color = Color.clear; + detail.AddProperty(() => credit.Name); + detail.AddProperty(() => credit.Licence); + detail.AddSpacer(); + foreach (var line in credit.Notice.Split('\n')) detail.AddProperty(() => line); + FocusHold.Focus(back.gameObject); + } + for (var i = 0; i < credits.Count; i++) + { + var index = i; + list.AddButton(credits[i].Name, () => Select(index)).Button.navigation = new Navigation { mode = Navigation.Mode.None }; + } + back = list.AddButton("Back", Leave).Button; + back.navigation = new Navigation { mode = Navigation.Mode.None }; + back.OnMoveAsObservable().Subscribe(move => + { + if (move.moveDir == MoveDirection.Up) Select(selected - 1); + else if (move.moveDir == MoveDirection.Down) Select(selected + 1); + }); + back.OnCancelAsObservable().Subscribe(_ => Leave()); + focus.Hold(); + Select(0); + return true; + } + // New Game: every way to set up a run, each with its brief. A release build lists only the modes; the other ways // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs index e0cd93134..f5f3a1ca4 100644 --- a/Assets/Scripts/UI/ScenarioPicker.cs +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -30,7 +30,7 @@ public sealed class ScenarioPicker private static readonly Color Accent = new Color(1, .4458f, .05f); private int _selected; private bool _launched; - private bool _restoreDeselect; + private readonly FocusHold _focus = new FocusHold(); private ScenarioPicker(PropertiesPanel list, PropertiesPanel detail, Action launch, Action back) { @@ -115,7 +115,7 @@ public sealed class ScenarioPicker picker._launchButton.OnCancelAsObservable().Subscribe(_ => picker.Back()); picker.StyleScrollbars(); - picker.HoldFocus(); + picker._focus.Hold(); picker.Select(0); // Layout is not live until the frame after Show; edit mode has no frames. if (Application.isPlaying) Observable.NextFrame().Subscribe(_ => picker.FitViewport()); @@ -229,7 +229,7 @@ public sealed class ScenarioPicker _rows[i].colors = colors; _rows[i].targetGraphic.color = i == _selected ? Accent : _rowFills[i]; } - Focus(_launchButton.gameObject); + FocusHold.Focus(_launchButton.gameObject); Reveal((RectTransform) _rows[_selected].transform); } @@ -253,36 +253,42 @@ public sealed class ScenarioPicker { if (_launched) return; _launched = true; - Focus(null); - ReleaseFocus(); + FocusHold.Focus(null); + _focus.Release(); _launch(Selected); } // Leaves the screen; the same path for the Back button and Cancel. private void Back() { - Focus(null); - ReleaseFocus(); + FocusHold.Focus(null); + _focus.Release(); _back(); } +} - // While the picker is up, a click on empty space must not drop focus from Launch, or the arrows and Enter go dead. - // The input module's flag is scene-wide, so the picker takes it for its own lifetime and gives it back on leaving. - private void HoldFocus() +// Keeps keyboard and gamepad focus on one control while a menu screen is up. While held, a click on empty space must +// not drop focus, or the arrows and Enter go dead. The input module's flag is scene-wide, so the screen takes it for its +// own lifetime and gives it back on leaving. The New Game and Credits screens both use it. +public sealed class FocusHold +{ + private bool _restoreDeselect; + + public void Hold() { if (EventSystem.current == null || !(EventSystem.current.currentInputModule is InputSystemUIInputModule module) || !module.deselectOnBackgroundClick) return; module.deselectOnBackgroundClick = false; _restoreDeselect = true; } - private void ReleaseFocus() + public void Release() { if (!_restoreDeselect) return; _restoreDeselect = false; if (EventSystem.current != null && EventSystem.current.currentInputModule is InputSystemUIInputModule module) module.deselectOnBackgroundClick = true; } - private static void Focus(GameObject target) + public static void Focus(GameObject target) { if (EventSystem.current != null) EventSystem.current.SetSelectedGameObject(target); } diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs new file mode 100644 index 000000000..68f9d68da --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -0,0 +1,363 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace Aetheria.Shared.Tests +{ + // The credits list against the repo's own licence files and the main menu that must reach it. + public class CreditsTests + { + private static string Root() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Assets", "Scripts", "UI", "MainMenu.cs"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + private static string Read(string relative) => File.ReadAllText(Path.Combine(Root(), relative)); + + private static System.Collections.Generic.IReadOnlyList Load() => Credits.All(Read(Credits.NounProjectFile)); + + // A file under Assets that looks like a licence, attribution, notice, readme or sources list. Each one is either + // credited (the credit names it as its LicenceFile) or reviewed below with the reason it is not. + private static readonly Regex LicenceLike = new Regex( + @"^(LICEN[CS]E|COPYING|NOTICE|README|SOURCES|_SOURCES|Attribution|UFL)|OFL", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Discovered files whose own text names no holder and no licence, so there is nothing true to credit. A new + // licence-like file is not here until someone has read it and written the reason. + private static readonly System.Collections.Generic.Dictionary ReviewedNotCredited = new System.Collections.Generic.Dictionary + { + ["Assets/Scripts/ServerShared/NIH/LinqFaster/readme.txt"] = "usage guide for LinqFaster; names no holder and no licence", + ["Assets/Plugins/LowPoly_AsteroidsPack/README.pdf"] = "how to customise the asteroid pack; names no holder and no licence", + ["Assets/Plugins/Celestial Body/Textures/_Sources.txt"] = "URLs of the source textures only; states no licence", + }; + + // Copyright lines found in source headers whose holder no credit shows, each with the reason; keyed like HeaderKeys. + private static readonly System.Collections.Generic.Dictionary ReviewedHeaders = new System.Collections.Generic.Dictionary + { + ["microsoftopen"] = "UniRx files derived from Rx.NET: the header names Microsoft Open Technologies and points at a License.txt that is not in the repo, so no licence is stated; decision owed", + }; + + // Bundled works with no licence-like file at all, found by reading Assets. Their licences are not stated in the + // repo, so they are not credited; each path must still exist, so this list cannot go stale unnoticed. + private static readonly string[] UnlicensedWorks = + { + "Assets/Plugins/Ink", + "Assets/Plugins/GPUTrail", + "Assets/Plugins/com.unity.uiextensions", + "Assets/Shaders/StableFluids", + }; + + // What each credited file itself states, written here independently of the credit text: these strings must + // appear in the file and in the credit's notice. + private static readonly (string file, string[] stated)[] Holders = + { + (Credits.TwemojiFile, new[] { "Jason Sofonia & Justine De Caires", "Twitter", "CC BY 4.0" }), + (Credits.PlaydeadFile, new[] { "Playdead" }), + (Credits.LiberationFile, new[] { "Google Corporation", "Red Hat, Inc." }), + (Credits.UniRxFile, new[] { "Yoshifumi Kawai" }), + (Credits.KdTreeFile, new[] { "Vili Vol\u010dini / viliwonka" }), + (Credits.SimplexNoiseFile, new[] { "Ashima Arts", "Ian McEwan", "Keijiro Takahashi" }), + (Credits.AmbientCgFile, new[] { "ambientCG" }), + (Credits.UnityShaderFile, new[] { "Unity Technologies" }), + (Credits.AdditiveUiFile, new[] { "Unity Technologies", "Supyrb", "Johannes Deml" }), + (Credits.AcesFile, new[] { "Academy of Motion Picture Arts and Sciences", "A.M.P.A.S." }), + (Credits.ClassicNoiseFile, new[] { "Ashima Arts", "Stefan Gustavson" }), + }; + + // Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the licence text shipped + // beside them (which must carry the licence and the stated holder), and every font listed must embed the stated string (read from the file, not from the credit) and be shown by the + // credit. The ids are TrueType name ids: 0 is the copyright notice. + private static readonly (string credit, string[] fonts, string embedded)[] FontCredits = + { + (Credits.UbuntuLicenceFile, new[] { "Assets/Fonts/Ubuntu/Ubuntu-R.ttf", "Assets/Fonts/Ubuntu/Ubuntu-L.ttf", "Assets/Fonts/Ubuntu/Ubuntu-B.ttf", "Assets/Fonts/Ubuntu/UbuntuMono-R.ttf" }, + "Copyright 2011 Canonical Ltd."), + (Credits.MontserratLicenceFile, new[] { "Assets/Fonts/Ubuntu/Montserrat-Thin.ttf" }, "Copyright(c) 2014 by Julieta Ulanovsky"), + }; + + // Fonts credited through a licence file in the repo (see Holders), not through their own text. + private static readonly string[] FontsCreditedByLicenceFile = { "Assets/Fonts/Emoji/Twemoji.ttf", "Assets/TextMesh Pro/Fonts/LiberationSans.ttf" }; + + // Fonts under Assets that are not credited, each with the reason; every one must be unreferenced by any prefab, + // scene, material or asset outside Assets/Fonts (checked), so shipping it is not a decision made by accident. + private static readonly System.Collections.Generic.Dictionary ReviewedFonts = new System.Collections.Generic.Dictionary + { + ["Assets/Fonts/Retro/LRT Oksana.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Retro/LRT Oksana Bold.ttf"] = "EULA forbids more than personal use; unreferenced; decision owed", + ["Assets/Fonts/Ubuntu/UbuntuMono-B.ttf"] = "unreferenced; same holder and licence as UbuntuMono-R, which the Ubuntu credit covers", + ["Assets/Fonts/ftn_SourceSansPro-Regular.otf"] = "unreferenced; embeds Adobe copyright under the SIL OFL 1.1; credit it if it is ever used", + }; + + private static string[] FontFiles() + { + var root = Root(); + return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => path.EndsWith(".ttf", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".otf", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // Every name-table record of a TrueType/OpenType file with the given name id, decoded (platform 3 as UTF-16BE, + // platform 1 as Latin-1). + private static System.Collections.Generic.List NameRecords(string relative, int nameId) + { + var d = File.ReadAllBytes(Path.Combine(Root(), relative)); + int U16(int at) => (d[at] << 8) | d[at + 1]; + long U32(int at) => ((long)d[at] << 24) | ((long)d[at + 1] << 16) | ((long)d[at + 2] << 8) | d[at + 3]; + var found = new System.Collections.Generic.List(); + for (int i = 0, n = U16(4); i < n; i++) + { + var rec = 12 + 16 * i; + if (System.Text.Encoding.ASCII.GetString(d, rec, 4) != "name") continue; + var table = (int)U32(rec + 8); + int count = U16(table + 2), strings = table + U16(table + 4); + for (var j = 0; j < count; j++) + { + var r = table + 6 + 12 * j; + if (U16(r + 6) != nameId) continue; + var text = System.Text.Encoding.GetEncoding(U16(r) == 3 ? "utf-16BE" : "latin1").GetString(d, strings + U16(r + 10), U16(r + 8)); + found.Add(text.Replace("\r", "").Trim()); + } + } + return found; + } + + private static string[] LicenceLikeFiles() + { + var root = Root(); + return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".meta")) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => LicenceLike.IsMatch(Path.GetFileName(path))) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // The ways a licence-like file can be accounted for; shared so the mutations in the report hit the same rule. + private static string[] Unaccounted(string[] found, System.Collections.Generic.IEnumerable credited) => + found.Where(path => !credited.Contains(path) && !ReviewedNotCredited.ContainsKey(path)).ToArray(); + + // The holder keys of every "Copyright ..." line in the source files under Assets: the year (when there is one) and + // the first two words of the holder, letters and digits only, lower case. Credits.cs is the credit text itself. + private static readonly Regex HeaderCopyright = new Regex(@"Copyright\s*(?:\([cC]\))?\s*(?:by\s+)?[<\[]?((?:\d{4}|[A-Z])[^\r\n]*)", RegexOptions.CultureInvariant); + + private static string Key(string text) => new string(text.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + + private static System.Collections.Generic.Dictionary HeaderKeys() + { + var root = Root(); + var keys = new System.Collections.Generic.Dictionary(); + foreach (var path in Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => new[] { ".shader", ".hlsl", ".cginc", ".cs", ".compute" }.Any(ext => path.EndsWith(ext)) && path != "Assets/Scripts/ServerShared/Credits.cs")) + foreach (var line in Read(path).Split('\n')) + foreach (Match match in HeaderCopyright.Matches(line)) + { + var words = Regex.Replace(Regex.Replace(match.Groups[1].Value, @"\.\s.*", ""), @"[<>,]", " ").Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(word => word != "by").ToArray(); + var year = words.Length > 0 && Regex.IsMatch(words[0], @"^\d{4}") ? 1 : 0; + if (year == 0 && !Regex.IsMatch(line, @"\([cC]\)")) continue; + keys.TryAdd(Key(string.Concat(words.Take(year + 2))), path); + } + return keys; + } + + // A copyright line in a source header is a notice the licence requires shown, so its holder is in a credit or in + // ReviewedHeaders with the reason; a removed credit, or a newly pasted header, fails here. + [Fact] + public void EveryCopyrightHolderInASourceHeaderIsCreditedOrReviewed() + { + var keys = HeaderKeys(); + Assert.Contains("2015academyof", keys.Keys); + Assert.Contains("2016unitytechnologies", keys.Keys); + Assert.Contains("2017supyrb", keys.Keys); + var shown = Key(string.Join(" ", Load().Select(credit => Normal(credit.Notice)))); + Assert.Equal(Array.Empty(), keys.Where(k => !shown.Contains(k.Key) && !ReviewedHeaders.ContainsKey(k.Key)).Select(k => $"{k.Key} in {k.Value}").ToArray()); + Assert.Equal(Array.Empty(), ReviewedHeaders.Keys.Where(k => !keys.ContainsKey(k)).ToArray()); + } + + [Fact] + public void EveryLicenceLikeFileUnderAssetsIsCreditedOrReviewed() + { + var root = Root(); + var found = LicenceLikeFiles(); + Assert.Contains(Credits.TwemojiFile, found); + Assert.Contains(Credits.LiberationFile, found); + var credited = Load().Select(credit => credit.LicenceFile).ToArray(); + Assert.Equal(Array.Empty(), Unaccounted(found, credited)); + Assert.Equal(Array.Empty(), credited.Where(path => !File.Exists(Path.Combine(root, path))).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => !found.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => credited.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), UnlicensedWorks.Where(path => !Directory.Exists(Path.Combine(root, path))).ToArray()); + } + + // Every holder the licence file states is in the credit that answers for it; the notices are typed into + // Credits.cs, so this is what keeps them true to the files. + [Fact] + public void EveryCreditShowsTheHoldersItsOwnFileStates() + { + var credits = Load(); + Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).Concat(FontCredits.Select(f => f.credit)).OrderBy(f => f, StringComparer.Ordinal), + credits.Select(credit => credit.LicenceFile).OrderBy(f => f, StringComparer.Ordinal)); + foreach (var (file, stated) in Holders) + { + var text = Normal(Read(file)); + var notice = Normal(credits.Single(credit => credit.LicenceFile == file).Notice); + foreach (var holder in stated) + { + Assert.True(text.Contains(Normal(holder)), $"{file} does not state '{holder}'"); + Assert.True(notice.Contains(Normal(holder)), $"the credit for {file} omits '{holder}'"); + } + // And every copyright line the file carries, whatever the table above says. + foreach (var line in Read(file).Split('\n')) + { + var match = CopyrightLine.Match(line); + if (!match.Success) continue; + var body = Regex.Replace(match.Groups[1].Value.Replace("[", "").Replace("]", "").Trim(), @"(\.\s.*|\s*All rights reserved)?\.?$", "", RegexOptions.IgnoreCase); + Assert.True(notice.Contains(Normal(body)), $"the credit for {file} omits its copyright line '{body}'"); + } + } + } + + private static readonly Regex CopyrightLine = new Regex(@"copyright\s*(?:\(c\)|\u00a9)?\s*(\[?\d.*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Dash variants and case folded, so the file's en dash and the screen's hyphen compare equal. + private static string Normal(string text) => text.Replace('\u2013', '-').Replace('\u2014', '-').Replace("\r", "").Replace(" by ", " ").ToLowerInvariant(); + + // The credit text is typed; the font files are the authority on who holds them. A wrong holder, or a font in the + // family that does not carry it, fails here. + [Fact] + public void FontCreditsShowWhatTheFontsThemselvesEmbed() + { + var credits = Load(); + foreach (var (creditFile, fonts, embedded) in FontCredits) + { + var notice = credits.Single(credit => credit.LicenceFile == creditFile).Notice; + var licenceText = Read(creditFile); + Assert.Contains(creditFile.Contains("UFL") ? "UBUNTU FONT LICENCE Version 1.0" : "SIL OPEN FONT LICENSE Version 1.1", licenceText); + Assert.True(Path.GetDirectoryName(creditFile) == Path.GetDirectoryName(fonts[0]).Replace('\\', '/'), "licence text sits beside its fonts"); + if (creditFile.Contains("OFL")) Assert.Contains(embedded, licenceText); + Assert.Contains(embedded, notice); + foreach (var font in fonts) + Assert.True(NameRecords(font, 0).Count > 0 && NameRecords(font, 0).All(record => record.StartsWith(embedded)), + $"{font} does not embed '{embedded}' as its copyright"); + } + Assert.Contains("Ubuntu Font Licence 1.0", NameRecords(FontCredits[0].fonts[0], 0)[0]); + Assert.Contains("Ubuntu Font Licence 1.0", credits.Single(credit => credit.LicenceFile == Credits.UbuntuLicenceFile).Licence); + Assert.Contains("SIL Open Font License, Version 1.1", NameRecords(FontCredits[1].fonts[0], 13)[0]); + Assert.Contains("SIL Open Font License 1.1", credits.Single(credit => credit.LicenceFile == Credits.MontserratLicenceFile).Licence); + } + + [Fact] + public void EveryFontUnderAssetsIsCreditedOrReviewedAsUnreferenced() + { + var root = Root(); + var accounted = FontCredits.SelectMany(f => f.fonts).Concat(FontsCreditedByLicenceFile).Concat(ReviewedFonts.Keys).ToArray(); + var fonts = FontFiles(); + Assert.Equal(Array.Empty(), fonts.Where(font => !accounted.Contains(font)).ToArray()); + Assert.Equal(Array.Empty(), accounted.Where(font => !fonts.Contains(font)).ToArray()); + // A reviewed font stays out of the credits only while nothing outside Assets/Fonts uses it. Prefabs and scenes + // reference the TMP SDF asset made from the font, not the .ttf, so the search follows the font guid, then the + // guid of every file under Assets/Fonts that references a guid already followed: the font's own SDF asset, and + // any other SDF asset carrying it in a fallback table. A use is a file outside Assets/Fonts naming any of them. + var files = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => new[] { ".prefab", ".unity", ".mat", ".asset", ".controller" }.Any(ext => path.EndsWith(ext))) + .Select(path => (path, text: Read(path))) + .ToArray(); + var inFonts = files.Where(file => file.path.StartsWith("Assets/Fonts/")).ToArray(); + var outside = files.Where(file => !file.path.StartsWith("Assets/Fonts/")).ToArray(); + foreach (var font in ReviewedFonts.Keys) + { + var followed = new System.Collections.Generic.HashSet { Regex.Match(Read(font + ".meta"), @"guid: (\w+)").Groups[1].Value }; + Assert.All(followed, guid => Assert.Equal(32, guid.Length)); + for (var grew = true; grew;) + { + grew = false; + foreach (var file in inFonts.Where(file => followed.Any(guid => file.text.Contains(guid))).ToArray()) + grew |= followed.Add(Regex.Match(Read(file.path + ".meta"), @"guid: (\w+)").Groups[1].Value); + } + Assert.Equal(Array.Empty(), outside.Where(user => followed.Any(guid => user.text.Contains(guid))).Select(user => user.path).ToArray()); + } + } + + [Fact] + public void TwemojiComesFirst() + { + var twemoji = Load()[0]; + Assert.Equal("Twemoji", twemoji.Name); + Assert.Contains("CC BY 4.0", twemoji.Licence); + } + + // The expectations are stated here, not derived from the parser: 81 icon credits between the heading and the + // "Other" sub-heading's two artists, named at the ends and at the line that was once split across two. + [Fact] + public void EveryIconCreditInTheAttributionFileIsShown() + { + var text = Read(Credits.NounProjectFile); + Assert.StartsWith(Credits.NounProjectHeading, text.TrimStart('\uFEFF')); + var lines = Credits.NounProjectLines(text).ToArray(); + Assert.Equal(82, lines.Length); + Assert.Equal("Tornado by Bart Laubsch", lines[0]); + Assert.Equal("Alien Abduction by Bart Laubsch", lines[1]); + Assert.Equal("Hurricane by icon 54 from Smashicons Space by Ben Davis", lines[2]); + Assert.Equal("Space 2 - Solid by Ben Davis", lines[3]); + Assert.Contains("Other", lines); + Assert.Equal("Missile War Weapon by Freepik", lines[^1]); + // One credit per line: every line but the sub-heading names an artist and does not stop mid-sentence. + foreach (var line in lines.Where(line => line != "Other")) + { + Assert.Contains(" by ", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotMatch(@"\s(from|by)$", line); + } + var notice = Load().Single(credit => credit.LicenceFile == Credits.NounProjectFile).Notice.Split('\n'); + Assert.Equal(lines, notice); + } + + [Fact] + public void AnAttributionFileWithoutItsHeadingIsRefused() + { + Assert.Throws(() => Credits.NounProjectLines("Tornado by Bart Laubsch\nAlien Abduction by Bart Laubsch").ToArray()); + Assert.Throws(() => Credits.NounProjectLines("Noun Project\n\n").ToArray()); + } + + [Fact] + public void EntriesNameTheirLicenceAndHolder() + { + foreach (var credit in Load()) + { + Assert.False(string.IsNullOrWhiteSpace(credit.Name)); + Assert.False(string.IsNullOrWhiteSpace(credit.Licence)); + Assert.False(string.IsNullOrWhiteSpace(credit.Notice)); + } + } + + [Fact] + public void AnEmptyEntryIsRefused() + { + Assert.Throws(() => Credits.Validate(new[] { new Credit { Name = "x", Licence = "", Notice = "n", LicenceFile = "f" } })); + } + + // The Unity menu is not compilable headlessly, so this reads its source with comments removed (a commented-out + // button or call must not satisfy it): the main menu must offer Credits, and ShowCredits must build the Shared + // list. It proves the wiring exists, not that the screen draws. + [Fact] + public void MainMenuOffersACreditsPathThatRendersTheList() + { + var menu = Regex.Replace(Read("Assets/Scripts/UI/MainMenu.cs"), @"/\*.*?\*/|//[^\n]*", "", RegexOptions.Singleline); + var main = menu.Substring(menu.IndexOf("private void ShowMain()", StringComparison.Ordinal)); + main = main.Substring(0, main.IndexOf("private bool ShowCredits()", StringComparison.Ordinal)); + Assert.Contains("AddButton(\"Credits\"", main); + Assert.Contains("ShowCredits()", main); + var credits = menu.Substring(menu.IndexOf("private bool ShowCredits()", StringComparison.Ordinal)); + credits = credits.Substring(0, credits.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); + Assert.Contains("Credits.All(", credits); + // A build missing the attribution file refuses with a dialog instead of throwing in the click handler. + Assert.Contains("attribution == null", credits); + Assert.Contains("Refuse(", credits); + } + } +}